From 17836748d7afe0521516c6a73c6a256680f05527 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 07:05:47 -0400 Subject: [PATCH] feat(vector-stores): add Databricks provider to TypeScript OSS SDK (#5824) Co-authored-by: kartik-mem0 --- docs/components/vectordbs/dbs/databricks.mdx | 63 +- mem0-ts/package.json | 12 +- mem0-ts/pnpm-lock.yaml | 593 +++++- mem0-ts/src/oss/src/config/manager.ts | 9 +- mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/memory/index.ts | 15 +- mem0-ts/src/oss/src/utils/factory.ts | 3 + .../src/oss/src/vector_stores/databricks.ts | 1627 +++++++++++++++ .../src/vector_stores/neptune_analytics.ts | 141 +- mem0-ts/src/oss/tests/config-manager.test.ts | 28 + mem0-ts/src/oss/tests/factory.unit.test.ts | 6 + .../oss/tests/vector-stores-compat.test.ts | 1823 ++++++++++++++++- mem0-ts/tsup.config.ts | 1 + mem0/vector_stores/neptune_analytics.py | 60 +- tests/vector_stores/test_neptune_analytics.py | 109 +- 15 files changed, 4399 insertions(+), 92 deletions(-) create mode 100644 mem0-ts/src/oss/src/vector_stores/databricks.ts diff --git a/docs/components/vectordbs/dbs/databricks.mdx b/docs/components/vectordbs/dbs/databricks.mdx index db0056e86..890b1edcb 100644 --- a/docs/components/vectordbs/dbs/databricks.mdx +++ b/docs/components/vectordbs/dbs/databricks.mdx @@ -6,7 +6,8 @@ description: "Use Databricks Vector Search as a serverless vector store in Mem0 ### Usage -```python + +```python Python import os from mem0 import Memory @@ -36,10 +37,44 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +// Requires the Databricks SQL driver (peer dependency): pnpm add @databricks/sql +import { Memory } from 'mem0ai/oss'; + +const config = { + vectorStore: { + provider: 'databricks', + config: { + workspaceUrl: 'https://your-workspace.databricks.com', + // SQL warehouse HTTP path, used for index writes (required) + httpPath: '/sql/1.0/warehouses/your-warehouse-id', + accessToken: 'your-access-token', + catalog: 'your_catalog', + schema: 'your_schema', + tableName: 'your_table', + collectionName: 'your_index_name', + embeddingModelDims: 1536, + }, + }, +}; + +const memory = new Memory(config); +const messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +] +await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); +``` + + ### Config Here are the parameters available for configuring Databricks Vector Search: + + | Parameter | Description | Default Value | | --- | --- | --- | | `workspace_url` | The URL of your Databricks workspace | **Required** | @@ -60,6 +95,32 @@ Here are the parameters available for configuring Databricks Vector Search: | `pipeline_type` | Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | | `warehouse_name` | Databricks SQL warehouse name (if using SQL warehouse) | `None` | | `query_type` | Query type: `ANN` or `HYBRID` | `ANN` | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `workspaceUrl` | The URL of your Databricks workspace (or pass `host`) | **Required** | +| `httpPath` | SQL warehouse HTTP path, used for index writes | **Required** | +| `accessToken` | Personal Access Token for authentication | `None` | +| `clientId` | Service principal client ID (alternative to `accessToken`) | `None` | +| `clientSecret` | Service principal client secret (required with `clientId`) | `None` | +| `endpointName` | Name of the Vector Search endpoint | `mem0_vector_search` | +| `endpointType` | Type of endpoint (`STANDARD` or `STORAGE_OPTIMIZED`) | `STANDARD` | +| `pipelineType` | Delta Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | +| `queryType` | Query type: `ANN` or `HYBRID` | `ANN` | +| `catalog` | Unity Catalog catalog name | `main` | +| `schema` | Unity Catalog schema name | `default` | +| `collectionName` | Vector Search index name | `mem0` | +| `tableName` | Source Delta table name | falls back to `collectionName` | +| `embeddingModelDims` | Dimension of self-managed embeddings | `1536` | +| `syncPollIntervalMs` | Poll interval while waiting for a `TRIGGERED` sync | `1000` | +| `syncTimeoutMs` | Timeout while waiting for an index sync | `300000` | + + +The TypeScript provider uses `DELTA_SYNC` indexes with self-managed embeddings: pass vectors directly. `DIRECT_ACCESS` indexes, Databricks-computed embeddings (`embedding_model_endpoint_name`), and Azure AD auth are Python-only today. It writes to the index through a SQL warehouse, so `httpPath` is required, and `@databricks/sql` must be installed as a peer dependency. + + + ### Authentication diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 497a1e1f9..a8d02c6d1 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -109,12 +109,13 @@ }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", - "@aws-sdk/client-neptune-graph": "3.966.0", + "@aws-sdk/client-neptune-graph": ">=3.0.0 <3.968.0", "@aws-sdk/client-s3vectors": "3.967.0", "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", + "@databricks/sql": "^1.16.0", "@google-cloud/aiplatform": "^6.8.0", "@google/genai": "^1.40.0", "@huggingface/transformers": "^3.0.0 || ^4.0.0", @@ -161,6 +162,12 @@ }, "@aws-sdk/client-bedrock-runtime": { "optional": true + }, + "@databricks/sql": { + "optional": true + }, + "@aws-sdk/client-neptune-graph": { + "optional": true } }, "engines": { @@ -198,7 +205,8 @@ "@modelcontextprotocol/sdk": "^1.25.4", "esbuild": ">=0.28.1", "undici@<6.27.0": ">=6.27.0 <8.0.0", - "@aws-sdk/client-bedrock-runtime": "3.967.0" + "@aws-sdk/client-bedrock-runtime": "3.967.0", + "@aws-sdk/client-neptune-graph": "3.966.0" } } } diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index 54b3d643a..5b9791705 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -28,6 +28,7 @@ overrides: esbuild: '>=0.28.1' undici@<6.27.0: '>=6.27.0 <8.0.0' '@aws-sdk/client-bedrock-runtime': 3.967.0 + '@aws-sdk/client-neptune-graph': 3.966.0 importers: @@ -54,6 +55,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250504.0 version: 4.20260611.1 + '@databricks/sql': + specifier: ^1.16.0 + version: 1.16.0 '@elastic/elasticsearch': specifier: ^9.0.0 version: 9.4.2 @@ -128,13 +132,13 @@ importers: version: 0.3.0 mongodb: specifier: ^7.0.0 - version: 7.2.0 + version: 7.2.0(socks@2.8.9) mysql2: specifier: ^3.0.0 version: 3.22.5(@types/node@22.19.21) natural: specifier: ^8.0.1 - version: 8.1.1(@opentelemetry/api@1.9.1) + version: 8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9) ollama: specifier: ^0.5.14 version: 0.5.18 @@ -208,6 +212,15 @@ importers: packages: + '@75lb/deep-merge@1.1.4': + resolution: {integrity: sha512-Fjmi8VSxoGF80wn8HSHTPfUKLSJ+aiQE7rXyrFV6apP8Xyj8v4bqdC7BBoklxjt671gUqP0yREwpDlUP5R/VyQ==} + engines: {node: '>=12.17'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + '@anthropic-ai/sdk@0.40.1': resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} @@ -756,6 +769,58 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + resolution: {integrity: sha512-dSZJD1uileOqRDfs5KsW6m43PAl3GqzQMcETbRT1Zjw2FRLQDtLKTA3+VWmlUUHhIz583WeTps91Sjee79cXbA==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [darwin] + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + resolution: {integrity: sha512-JEe7TqLrXrAoN3SWsi7NgjAo6V+7jEvBwLswwhl0qUp1MoPrZT+y5Kf49vuHPlh61vFgi2F6NlCNfdB/8wpihg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [darwin] + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + resolution: {integrity: sha512-meJ2JF5w4qEiaiI9TNwg2iMgasadqeKSDAw3r3hWTEdzKwUY7saMTFNcjumwR8R3cQMCFJfp3YBOwKS5/hvN9A==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + resolution: {integrity: sha512-YTCPs11w6purXCQwJBCh99oHBwTEW4q46OWxO9Rnddo1wJd8EPBa3Misw08URoUVuVuEfYGy5YtSvuPfkkj8Vw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + resolution: {integrity: sha512-wIEJX2mtoCc/KGOzzbxXwOR5aUB5dBSUG1Ypp+JLI28XsZB2eCLcP4jyAQ+Uklxn+SU1hICuok+30t/7wSvKUg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + resolution: {integrity: sha512-0wtWdOxYh7BfeklDpI0tpTk/ljdjJmCQsNFPLy2C7EnK60J3sroYzTaFqgn8Qqc7T03VHZl/6GG1pXC3zPuU1g==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + resolution: {integrity: sha512-gS4y1hIIDitr1d9bW6yWEkn4wMW7abinDwFFVUJtYsWkFS+rMq/8CwAYioqNprXQP+XEQS7fLiLYq/BYsQB3Aw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [win32] + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + resolution: {integrity: sha512-zl6KtfB02eVsBNZEQ/kf/dhmesUqbVGwhSFb5oKlmtyj/1kah9R1FRKBVKTLt3v5n9LnjBnomV2s+DRxRSh4rA==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [win32] + + '@databricks/sql@1.16.0': + resolution: {integrity: sha512-kaSBjYUT0rDYsrouvPakwRjs030KKvImOTHRG95EsYOLkNN+/YvJ8JgQ0vQjvgT3NUREoGys+SQ4ZzqwHuwoCA==} + engines: {node: '>=14.0.0'} + '@datastructures-js/deque@1.0.8': resolution: {integrity: sha512-PSBhJ2/SmeRPRHuBv7i/fHWIdSC3JTyq56qb+Rq0wjOagi0/fdV5/B/3Md5zFZus/W6OkSPMaxMKKMNMrSmubg==} @@ -1763,6 +1828,9 @@ packages: resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==} engines: {node: '>=20.0.0'} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1793,6 +1861,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/command-line-args@5.2.0': + resolution: {integrity: sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==} + + '@types/command-line-usage@5.0.2': + resolution: {integrity: sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1820,6 +1894,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.3.0': + resolution: {integrity: sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==} + '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} @@ -1829,6 +1906,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pad-left@2.1.1': + resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} + '@types/pg@8.11.0': resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} @@ -1952,6 +2032,10 @@ packages: anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + apache-arrow@13.0.0: + resolution: {integrity: sha512-3gvCX0GDawWz6KFNC28p65U+zGh/LZ6ZNKWNu74N6CQlKzxeoWHpi4CgEQsgRSEMuyrIIXi1Ea2syja7dwcHvw==} + hasBin: true + apparatus@0.0.10: resolution: {integrity: sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg==} engines: {node: '>=0.2.6'} @@ -1962,6 +2046,18 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.3: + resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} + engines: {node: '>=12.17'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} @@ -2024,6 +2120,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + better-sqlite3@12.10.0: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} @@ -2142,6 +2242,10 @@ packages: resolution: {integrity: sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2277,10 +2381,22 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.1: + resolution: {integrity: sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==} + engines: {node: '>=12.20.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + compromise@14.15.1: resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} engines: {node: '>=12.0.0'} @@ -2320,10 +2436,17 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + cuint@0.2.2: + resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -2368,6 +2491,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -2376,6 +2503,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2489,6 +2620,24 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -2568,6 +2717,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2579,6 +2732,9 @@ packages: resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==} hasBin: true + flatbuffers@23.5.26: + resolution: {integrity: sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==} + flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} @@ -2677,6 +2833,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2845,6 +3005,10 @@ packages: resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==} engines: {node: '>=18.12.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2859,6 +3023,11 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2899,6 +3068,10 @@ packages: is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -3067,6 +3240,9 @@ packages: node-notifier: optional: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3089,6 +3265,10 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -3214,6 +3394,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -3230,6 +3413,14 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru-cache@9.1.2: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} @@ -3238,6 +3429,10 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lz4@0.6.5: + resolution: {integrity: sha512-KSZcJU49QZOlJSItaeIU3p8WoAvkTmD9fJqeahQXNu1iQ/kR0/mQLdbrK8JY9MY8f6AhJoMrihp1nu1xDbscSQ==} + engines: {node: '>= 0.10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3390,6 +3585,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -3403,6 +3601,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + nice-grpc-client-middleware-retry@3.1.15: resolution: {integrity: sha512-fXfNNdtjCQzc3O/w3WsK1AOU+xdE1V7FCm4FEQWS/UUVsV1616S2oryvWqsZAF8aOvuMir8lFtNmgH3DeP+PBg==} @@ -3462,6 +3664,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -3473,6 +3679,10 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + ollama@0.5.18: resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==} @@ -3510,6 +3720,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openai@4.104.0: resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} hasBin: true @@ -3522,6 +3736,9 @@ packages: zod: optional: true + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -3554,12 +3771,24 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} packet-reader@1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + pad-left@2.1.0: + resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} + engines: {node: '>=0.10.0'} + pako@2.2.0: resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} @@ -3761,6 +3990,13 @@ packages: resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} engines: {node: '>=12.0.0'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3831,6 +4067,10 @@ packages: resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==} engines: {node: '>= 18.19.0'} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3955,9 +4195,21 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + snappyjs@0.7.0: resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -4012,6 +4264,10 @@ packages: stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-read-all@3.0.1: + resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} + engines: {node: '>=10'} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -4088,6 +4344,11 @@ packages: resolution: {integrity: sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA==} engines: {node: '>=0.2.6'} + table-layout@3.0.2: + resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} + engines: {node: '>=12.17'} + hasBin: true + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -4117,6 +4378,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thrift@0.16.0: + resolution: {integrity: sha512-W8DpGyTPlIaK3f+e1XOCLxefaUWXtrOXAaVIDbfYhmVyriYeAKgsBVFNJUV1F9SQ2SPt2sG44AZQxSGwGj/3VA==} + engines: {node: '>= 10.18.0'} + thrift@0.23.0: resolution: {integrity: sha512-j7F1ls8JogClU88Ta/pwD/OzYuiFeD6Z5GoWw7ip+jcDhcNYFKgfXYEsyLXYpiNfJO94fbLnITGxyfZ19YzA6Q==} engines: {node: '>= 10.18.0'} @@ -4256,6 +4521,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4370,6 +4643,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4423,6 +4700,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + xxhashjs@0.2.2: + resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4470,6 +4750,11 @@ packages: snapshots: + '@75lb/deep-merge@1.1.4': + dependencies: + lodash: 4.18.1 + typical: 7.3.0 + '@anthropic-ai/sdk@0.40.1': dependencies: '@types/node': 18.19.130 @@ -5720,6 +6005,60 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + optional: true + + '@databricks/sql@1.16.0': + dependencies: + apache-arrow: 13.0.0 + commander: 9.5.0 + flatbuffers: 23.5.26 + node-fetch: 2.7.0 + node-int64: 0.4.0 + open: 8.4.2 + openid-client: 5.7.1 + proxy-agent: 6.5.0 + thrift: 0.16.0 + uuid: 11.1.1 + winston: 3.19.0 + optionalDependencies: + '@databricks/databricks-sql-kernel-darwin-arm64': 0.2.0 + '@databricks/databricks-sql-kernel-darwin-x64': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-win32-arm64-msvc': 0.2.0 + '@databricks/databricks-sql-kernel-win32-x64-msvc': 0.2.0 + lz4: 0.6.5 + transitivePeerDependencies: + - '@75lb/nature' + - bufferutil + - encoding + - supports-color + - utf-8-validate + '@datastructures-js/deque@1.0.8': {} '@elastic/elasticsearch@9.4.2': @@ -6755,6 +7094,8 @@ snapshots: '@supabase/realtime-js': 2.108.1 '@supabase/storage-js': 2.108.1 + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -6793,6 +7134,10 @@ snapshots: dependencies: '@types/node': 22.19.21 + '@types/command-line-args@5.2.0': {} + + '@types/command-line-usage@5.0.2': {} + '@types/estree@1.0.9': {} '@types/graceful-fs@4.1.9': @@ -6827,6 +7172,8 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.3.0': {} + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -6837,6 +7184,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/pad-left@2.1.1': {} + '@types/pg@8.11.0': dependencies: '@types/node': 22.19.21 @@ -6965,6 +7314,21 @@ snapshots: anynum@1.0.1: {} + apache-arrow@13.0.0: + dependencies: + '@types/command-line-args': 5.2.0 + '@types/command-line-usage': 5.0.2 + '@types/node': 20.3.0 + '@types/pad-left': 2.1.1 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + flatbuffers: 23.5.26 + json-bignum: 0.0.3 + pad-left: 2.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + apparatus@0.0.10: dependencies: sylvester: 0.0.21 @@ -6973,6 +7337,14 @@ snapshots: argparse@2.0.1: {} + array-back@3.1.0: {} + + array-back@6.2.3: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + async-limiter@1.0.1: {} async@3.2.6: {} @@ -7058,6 +7430,8 @@ snapshots: baseline-browser-mapping@2.10.35: {} + basic-ftp@5.3.1: {} + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 @@ -7168,6 +7542,10 @@ snapshots: adm-zip: 0.5.17 long: 5.2.5 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7292,8 +7670,26 @@ snapshots: dependencies: delayed-stream: 1.0.0 + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.1: + dependencies: + array-back: 6.2.3 + chalk-template: 0.4.0 + table-layout: 3.0.2 + typical: 7.3.0 + transitivePeerDependencies: + - '@75lb/nature' + commander@4.1.1: {} + commander@9.5.0: {} + compromise@14.15.1: dependencies: efrt: 2.7.0 @@ -7344,8 +7740,13 @@ snapshots: crypt@0.0.2: {} + cuint@0.2.2: + optional: true + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + dayjs@1.11.21: {} debug@4.4.3(supports-color@5.5.0): @@ -7377,6 +7778,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -7385,6 +7788,12 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -7499,6 +7908,20 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -7590,6 +8013,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -7608,6 +8035,8 @@ snapshots: kolorist: 1.8.0 read-pkg: 8.1.0 + flatbuffers@23.5.26: {} + flatbuffers@25.9.23: {} fn.name@1.1.0: {} @@ -7701,6 +8130,14 @@ snapshots: get-stream@6.0.1: {} + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -7923,6 +8360,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -7935,6 +8374,8 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -7959,6 +8400,10 @@ snapshots: is-unsafe@1.0.1: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -8325,6 +8770,8 @@ snapshots: - supports-color - ts-node + jose@4.15.9: {} + joycon@3.1.1: {} js-tiktoken@1.0.21: @@ -8343,6 +8790,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-bignum@0.0.3: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@3.0.2: {} @@ -8435,6 +8884,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.18.1: {} + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -8454,10 +8905,24 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + lru-cache@9.1.2: {} lru.min@1.1.4: {} + lz4@0.6.5: + dependencies: + buffer: 5.7.1 + cuint: 0.2.2 + nan: 2.28.0 + xxhashjs: 0.2.2 + optional: true + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8546,16 +9011,18 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0: + mongodb@7.2.0(socks@2.8.9): dependencies: '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 + optionalDependencies: + socks: 2.8.9 - mongoose@9.7.0: + mongoose@9.7.0(socks@2.8.9): dependencies: kareem: 3.3.0 - mongodb: 7.2.0 + mongodb: 7.2.0(socks@2.8.9) mpath: 0.9.0 mquery: 6.0.0 ms: 2.1.3 @@ -8599,18 +9066,21 @@ snapshots: dependencies: lru.min: 1.1.4 + nan@2.28.0: + optional: true + napi-build-utils@2.0.0: {} natural-compare@1.4.0: {} - natural@8.1.1(@opentelemetry/api@1.9.1): + natural@8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9): dependencies: afinn-165: 2.0.2 afinn-165-financialmarketnews: 3.0.0 apparatus: 0.0.10 dotenv: 17.4.2 memjs: 1.3.2 - mongoose: 9.7.0 + mongoose: 9.7.0(socks@2.8.9) pg: 8.21.0 redis: 5.12.1(@opentelemetry/api@1.9.1) safe-stable-stringify: 2.5.0 @@ -8633,6 +9103,8 @@ snapshots: neo-async@2.6.2: {} + netmask@2.1.1: {} + nice-grpc-client-middleware-retry@3.1.15: dependencies: abort-controller-x: 0.5.0 @@ -8695,12 +9167,16 @@ snapshots: object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-hash@3.0.0: {} object-keys@1.1.1: {} obuf@1.1.2: {} + oidc-token-hash@5.2.0: {} + ollama@0.5.18: dependencies: whatwg-fetch: 3.6.20 @@ -8751,6 +9227,12 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openai@4.104.0(ws@5.2.5)(zod@3.25.76): dependencies: '@types/node': 18.19.130 @@ -8766,6 +9248,13 @@ snapshots: transitivePeerDependencies: - encoding + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + p-finally@1.0.0: {} p-limit@2.3.0: @@ -8796,10 +9285,32 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-json-from-dist@1.0.1: {} packet-reader@1.0.0: {} + pad-left@2.1.0: + dependencies: + repeat-string: 1.6.1 + pako@2.2.0: {} parse-json@5.2.0: @@ -8996,6 +9507,21 @@ snapshots: '@types/node': 22.19.21 long: 5.3.2 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pstree.remy@1.1.8: {} @@ -9075,6 +9601,8 @@ snapshots: - '@node-rs/xxhash' - '@opentelemetry/api' + repeat-string@1.6.1: {} + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -9232,8 +9760,23 @@ snapshots: slash@3.0.0: {} + smart-buffer@4.2.0: {} + snappyjs@0.7.0: {} + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -9281,6 +9824,8 @@ snapshots: dependencies: stubs: 3.0.0 + stream-read-all@3.0.1: {} + stream-shift@1.0.3: {} string-length@4.0.2: @@ -9354,6 +9899,18 @@ snapshots: sylvester@0.0.21: {} + table-layout@3.0.2: + dependencies: + '@75lb/deep-merge': 1.1.4 + array-back: 6.2.3 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + stream-read-all: 3.0.1 + typical: 7.3.0 + wordwrapjs: 5.1.1 + transitivePeerDependencies: + - '@75lb/nature' + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -9402,6 +9959,17 @@ snapshots: dependencies: any-promise: 1.3.0 + thrift@0.16.0: + dependencies: + browser-or-node: 1.3.0 + isomorphic-ws: 4.0.1(ws@5.2.5) + node-int64: 0.4.0 + q: 1.5.1 + ws: 5.2.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + thrift@0.23.0: dependencies: browser-or-node: 1.3.0 @@ -9533,6 +10101,10 @@ snapshots: typescript@5.5.4: {} + typical@4.0.0: {} + + typical@7.3.0: {} + ufo@1.6.4: {} uglify-js@3.19.3: @@ -9649,6 +10221,8 @@ snapshots: wordwrap@1.0.0: {} + wordwrapjs@5.1.1: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9684,6 +10258,11 @@ snapshots: xxhash-wasm@1.1.0: {} + xxhashjs@0.2.2: + dependencies: + cuint: 0.2.2 + optional: true + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index c4ad83116..b2803d1b2 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -60,9 +60,14 @@ export class ConfigManager { })(), }, vectorStore: { - provider: + // Every factory already matches the provider case-insensitively, so a capitalized + // name constructs the right store -- but the `provider === "memory"` comparisons that + // pick per-provider entity-store settings do not. Normalize once, here, so those + // comparisons cannot silently miss. + provider: ( userConfig.vectorStore?.provider || - DEFAULT_MEMORY_CONFIG.vectorStore.provider, + DEFAULT_MEMORY_CONFIG.vectorStore.provider + ).toLowerCase(), config: (() => { const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config; const userConf = userConfig.vectorStore?.config; diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 84c6316e6..b883c6355 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -36,6 +36,7 @@ export * from "./vector_stores/langchain"; export * from "./vector_stores/vectorize"; export * from "./vector_stores/azure_ai_search"; export * from "./vector_stores/pgvector"; +export * from "./vector_stores/databricks"; export * from "./vector_stores/neptune_analytics"; export * from "./vector_stores/elasticsearch"; export * from "./vector_stores/upstash_vector"; diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 23a6708e8..4e48dc181 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -7,6 +7,7 @@ import { Message, SearchFilters, SearchResult, + VectorStoreConfig, } from "../types"; import { EmbedderFactory, @@ -286,18 +287,24 @@ export class Memory { private async getEntityStore(): Promise { if (!this._entityStore) { + const entityProvider = this.config.vectorStore.provider; const entityCollectionName = `${this.collectionName}_entities`; - const entityConfig = { + const entityConfig: VectorStoreConfig = { ...this.config.vectorStore.config, collectionName: entityCollectionName, }; // For file-based stores (memory/SQLite), always use a separate DB for entities - if (this.config.vectorStore.provider === "memory") { + if (entityProvider === "memory") { const basePath = entityConfig.dbPath || getDefaultVectorStoreDbPath(); entityConfig.dbPath = basePath.replace(/\.db$/, "_entities.db"); } + if (entityProvider === "databricks") { + entityConfig.tableName = entityConfig.tableName + ? `${entityConfig.tableName}_entities` + : entityCollectionName; + } this._entityStore = VectorStoreFactory.create( - this.config.vectorStore.provider, + entityProvider, entityConfig, ); await this._entityStore.initialize(); @@ -1747,7 +1754,7 @@ export class Memory { await this.db.reset(); // Check provider before attempting deleteCol - if (this.config.vectorStore.provider.toLowerCase() !== "langchain") { + if (this.config.vectorStore.provider !== "langchain") { try { await this.vectorStore.deleteCol(); } catch (e) { diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 2bed7f484..fcafde537 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -55,6 +55,7 @@ import { HuggingFaceEmbedder } from "../embeddings/huggingface"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; +import { DatabricksVectorStore } from "../vector_stores/databricks"; import { NeptuneAnalyticsVectorStore } from "../vector_stores/neptune_analytics"; import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; @@ -173,6 +174,8 @@ export class VectorStoreFactory { return new VertexAIVectorSearch(config as any); case "pgvector": return new PGVector(config as any); + case "databricks": + return new DatabricksVectorStore(config as any); case "neptune": case "neptune-analytics": return new NeptuneAnalyticsVectorStore(config as any); diff --git a/mem0-ts/src/oss/src/vector_stores/databricks.ts b/mem0-ts/src/oss/src/vector_stores/databricks.ts new file mode 100644 index 000000000..627e522ee --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/databricks.ts @@ -0,0 +1,1627 @@ +import axios, { AxiosInstance } from "axios"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const DEFAULT_PAGE_SIZE = 100; +const MAX_QUERY_RESULTS = 10_000; +const MAX_FULL_TEXT_RESULTS = 200; +const DEFAULT_SYNC_POLL_INTERVAL_MS = 1000; +const DEFAULT_SYNC_TIMEOUT_MS = 5 * 60 * 1000; +const DATABRICKS_SERVER_FILTER_KEYS = new Set([ + "memory_id", + "user_id", + "agent_id", + "run_id", +]); + +interface DatabricksConfig extends VectorStoreConfig { + workspaceUrl?: string; + host?: string; + httpPath: string; + accessToken?: string; + clientId?: string; + clientSecret?: string; + endpointName?: string; + endpointType?: "STANDARD" | "STORAGE_OPTIMIZED"; + pipelineType?: "TRIGGERED" | "CONTINUOUS"; + queryType?: "ANN" | "HYBRID"; + catalog?: string; + schema?: string; + tableName?: string; + embeddingModelDims?: number; + syncPollIntervalMs?: number; + syncTimeoutMs?: number; + sqlClient?: DatabricksSqlClientLike; + httpClient?: DatabricksHttpClientLike; +} + +interface DatabricksSqlClientLike { + connect(options: Record): Promise; + openSession(): Promise; + close?(): Promise; +} + +interface DatabricksSqlSessionLike { + executeStatement(statement: string): Promise; + close?(): Promise; +} + +interface DatabricksSqlOperationLike { + fetchAll(): Promise>>; + close?(): Promise; +} + +interface DatabricksHttpClientLike { + get(url: string, config?: Record): Promise<{ data: any }>; + post( + url: string, + data?: any, + config?: Record, + ): Promise<{ data: any }>; + delete(url: string, config?: Record): Promise<{ data: any }>; +} + +interface DatabricksVector { + id: string; + payload: Record; +} + +function buildDatabricksAuthorizationDetails( + indexName: string, + operation: "ReadVectorIndex" | "WriteVectorIndex", +): string { + return JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: indexName, + operation, + }, + ]); +} + +function validateIdentifier( + name: string, + label: string = "identifier", +): string { + if (!SAFE_IDENTIFIER_RE.test(name)) { + throw new Error( + `Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` + + `must start with a letter or underscore, and be at most 128 characters.`, + ); + } + return name; +} + +function extractHostAndWorkspaceUrl(config: DatabricksConfig): { + host: string; + workspaceUrl: string; +} { + if (config.workspaceUrl) { + const url = new URL(config.workspaceUrl); + return { + host: url.host, + workspaceUrl: `${url.protocol}//${url.host}`, + }; + } + + if (config.host) { + return { + host: config.host, + workspaceUrl: `https://${config.host}`, + }; + } + + throw new Error( + "Databricks vector store requires either workspaceUrl or host.", + ); +} + +// `Memory.search()` hands keywordSearch() an already-lemmatized query, so BM25 has to score +// against lemmatized text. Same contract as baidu.ts / pgvector.ts. +function lemmatizedText(payload: Record): string { + const data = typeof payload.data === "string" ? payload.data : ""; + return typeof payload.textLemmatized === "string" && + payload.textLemmatized.length > 0 + ? payload.textLemmatized + : data; +} + +function formatSqlValue(value: any): string { + if (value === null || value === undefined) { + return "NULL"; + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Databricks vector store only accepts finite numbers."); + } + return String(value); + } + if (Array.isArray(value)) { + return `array(${value.map((entry) => formatSqlValue(entry)).join(", ")})`; + } + const json = + typeof value === "string" ? value : JSON.stringify(value ?? {}) || "{}"; + // Databricks/Spark SQL treats backslash as an escape char in string literals, + // so a trailing "\" would consume the closing quote (breaking out of the + // literal) and any backslash would be dropped on write. Escape backslashes + // before doubling quotes so the literal is injection-safe and round-trips. + const escaped = json.replace(/\\/g, "\\\\").replace(/'/g, "''"); + return `'${escaped}'`; +} + +function extractRowValue(row: Record, keys: string[]): any { + for (const key of keys) { + if (key in row) { + return row[key]; + } + const upper = key.toUpperCase(); + if (upper in row) { + return row[upper]; + } + } + return undefined; +} + +function isPlainObject(value: any): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFilterValue(value: any): any { + if (Array.isArray(value)) { + return value.map((entry) => normalizeFilterValue(entry)); + } + return value; +} + +function isDatabricksServerFilterKey(key: string): boolean { + return DATABRICKS_SERVER_FILTER_KEYS.has(key); +} + +function mergeDatabricksFilters( + target: Record, + source: Record, +): boolean { + for (const [key, value] of Object.entries(source)) { + if ( + key in target && + JSON.stringify(target[key]) !== JSON.stringify(value) + ) { + return false; + } + target[key] = value; + } + return true; +} + +function buildSimpleDatabricksFilter( + key: string, + value: any, +): Record | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + return { + [safeKey]: normalizeFilterValue(value), + }; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return { [safeKey]: normalizeFilterValue(operand) }; + case "ne": + return { [`${safeKey} NOT`]: normalizeFilterValue(operand) }; + case "gt": + return { [`${safeKey} >`]: normalizeFilterValue(operand) }; + case "gte": + return { [`${safeKey} >=`]: normalizeFilterValue(operand) }; + case "lt": + return { [`${safeKey} <`]: normalizeFilterValue(operand) }; + case "lte": + return { [`${safeKey} <=`]: normalizeFilterValue(operand) }; + case "in": + return Array.isArray(operand) + ? { [safeKey]: normalizeFilterValue(operand) } + : null; + case "nin": + return Array.isArray(operand) && operand.length === 1 + ? { [`${safeKey} NOT`]: normalizeFilterValue(operand[0]) } + : null; + default: + return null; + } +} + +function buildStandardDatabricksFiltersFromClauses( + clauses: Array<[string, any]>, +): Record | undefined { + const result: Record = {}; + + for (const [key, value] of clauses) { + const translated = buildSimpleDatabricksFilter(key, value); + if (!translated) { + continue; + } + if (!mergeDatabricksFilters(result, translated)) { + return undefined; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function buildStorageOptimizedDatabricksFilterClause( + key: string, + value: any, +): string | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + return value.length > 0 + ? `${safeKey} IN (${value + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + } + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(value))}`; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(operand))}`; + case "ne": + // SQL three-valued logic: `col != x` is NULL (excluded) when col IS NULL, + // but the local matcher keeps rows with a missing field for `ne`. Match it. + return `(${safeKey} IS NULL OR ${safeKey} != ${formatSqlValue(normalizeFilterValue(operand))})`; + case "gt": + return `${safeKey} > ${formatSqlValue(normalizeFilterValue(operand))}`; + case "gte": + return `${safeKey} >= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lt": + return `${safeKey} < ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lte": + return `${safeKey} <= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "in": + return Array.isArray(operand) && operand.length > 0 + ? `${safeKey} IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + case "nin": + // Same NULL-handling as `ne` above. + return Array.isArray(operand) && operand.length > 0 + ? `(${safeKey} IS NULL OR ${safeKey} NOT IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")}))` + : null; + default: + return null; + } +} + +function collectConjunctiveDatabricksFilters( + filters?: SearchFilters, +): Array<[string, any]> { + if (!filters || Object.keys(filters).length === 0) { + return []; + } + + const clauses: Array<[string, any]> = []; + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + if (!isPlainObject(entry)) { + continue; + } + clauses.push(...collectConjunctiveDatabricksFilters(entry)); + } + continue; + } + + if (key === "$or" || key === "$not") { + continue; + } + + clauses.push([key, value]); + } + + return clauses; +} + +// True iff `filters` has no `$or`/`$not` at any nesting depth (recursing through +// `$and`), i.e. every entry is something collectConjunctiveDatabricksFilters can see. +function isFullyConjunctiveDatabricksFilter(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return false; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + !isFullyConjunctiveDatabricksFilter(entry as SearchFilters), + ) + ) { + return false; + } + continue; + } + + if (key === "$or" || key === "$not") { + return false; + } + } + + return true; +} + +function buildDatabricksServerFilters( + endpointType: "STANDARD" | "STORAGE_OPTIMIZED", + filters?: SearchFilters, +): { filters?: string; filters_json?: string } { + const clauses = collectConjunctiveDatabricksFilters(filters); + + if (clauses.length === 0) { + return {}; + } + + if (endpointType === "STORAGE_OPTIMIZED") { + const translatedClauses = clauses + .map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ) + .filter((clause): clause is string => Boolean(clause)); + + return translatedClauses.length > 0 + ? { filters: translatedClauses.join(" AND ") } + : {}; + } + + const translatedFilters = buildStandardDatabricksFiltersFromClauses(clauses); + + return translatedFilters + ? { filters_json: JSON.stringify(translatedFilters) } + : {}; +} + +export class DatabricksVectorStore implements VectorStore { + private readonly host: string; + private readonly workspaceUrl: string; + private readonly httpPath: string; + private readonly accessToken?: string; + private readonly clientId?: string; + private readonly clientSecret?: string; + private readonly endpointName: string; + private readonly endpointType: "STANDARD" | "STORAGE_OPTIMIZED"; + private readonly pipelineType: "TRIGGERED" | "CONTINUOUS"; + private readonly queryType: "ANN" | "HYBRID"; + private readonly dimension: number; + private readonly catalog: string; + private readonly schema: string; + private readonly tableName: string; + private readonly indexName: string; + private readonly fullTableName: string; + private readonly fullIndexName: string; + private readonly syncPollIntervalMs: number; + private readonly syncTimeoutMs: number; + private sqlClient: DatabricksSqlClientLike | null; + private readonly httpClient: DatabricksHttpClientLike; + private session?: DatabricksSqlSessionLike; + private _sessionPromise?: Promise; + private _initPromise?: Promise; + private sqlModulePromise?: Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }>; + private indexSyncWait: Promise | null = null; + private indexSyncRunning = false; + private indexSyncQueued = false; + private indexSyncError: unknown = null; + private readonly oauthTokens = new Map< + string, + { accessToken: string; expiresAt: number } + >(); + + constructor(config: DatabricksConfig) { + const { host, workspaceUrl } = extractHostAndWorkspaceUrl(config); + + this.host = host; + this.workspaceUrl = workspaceUrl; + this.httpPath = config.httpPath; + this.accessToken = config.accessToken; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.endpointName = config.endpointName || "mem0_vector_search"; + this.endpointType = config.endpointType || "STANDARD"; + this.pipelineType = config.pipelineType || "TRIGGERED"; + this.queryType = config.queryType || "ANN"; + this.dimension = config.embeddingModelDims || config.dimension || 1536; + this.catalog = validateIdentifier(config.catalog || "main", "catalog"); + this.schema = validateIdentifier(config.schema || "default", "schema"); + this.indexName = validateIdentifier( + config.collectionName || "mem0", + "collectionName", + ); + this.tableName = validateIdentifier( + config.tableName || this.indexName, + "tableName", + ); + this.fullTableName = `${this.catalog}.${this.schema}.${this.tableName}`; + this.fullIndexName = `${this.catalog}.${this.schema}.${this.indexName}`; + this.syncPollIntervalMs = + config.syncPollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS; + this.syncTimeoutMs = config.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; + this.sqlClient = config.sqlClient ?? null; + this.httpClient = config.httpClient || this.createHttpClient(); + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.pipelineType !== "TRIGGERED" + ) { + throw new Error( + "Databricks storage-optimized endpoints only support TRIGGERED pipelineType.", + ); + } + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.dimension % 16 !== 0 + ) { + throw new Error( + "Databricks storage-optimized endpoints require dimensions divisible by 16.", + ); + } + + this.initialize().catch(console.error); + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize().catch((error) => { + // A failed init (e.g. a cold/auto-suspended warehouse at startup) must not be cached + // forever -- clear it so the next public call retries instead of replaying the + // rejection. Every step is idempotent (CREATE ... IF NOT EXISTS / ensure*), so a + // retry is safe. + this._initPromise = undefined; + throw error; + }); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + await this.executeSql( + `CREATE SCHEMA IF NOT EXISTS ${this.catalog}.${this.schema}`, + ); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.fullTableName} ( + memory_id STRING, + embedding ARRAY, + payload STRING, + text_lemmatized STRING, + user_id STRING, + agent_id STRING, + run_id STRING + ) USING DELTA + TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + await this.ensureChangeDataFeedEnabled(); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.catalog}.${this.schema}.memory_migrations ( + user_id STRING + ) USING DELTA + `); + + await this.ensureEndpointExists(); + await this.waitForEndpointReadiness(); + await this.ensureIndexExists(); + await this.waitForIndexReadiness(); + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + await this.initialize(); + + const values = vectors.map((vector, index) => { + this.assertVectorDimension(vector, "Vector"); + const payload = payloads[index] || {}; + const sessionValues = this.extractSessionValues(payload); + return `( + ${formatSqlValue(ids[index])}, + ${formatSqlValue(vector)}, + ${formatSqlValue(JSON.stringify(payload))}, + ${formatSqlValue(lemmatizedText(payload))}, + ${formatSqlValue(sessionValues.user_id)}, + ${formatSqlValue(sessionValues.agent_id)}, + ${formatSqlValue(sessionValues.run_id)} + )`; + }); + + await this.executeSql(` + INSERT INTO ${this.fullTableName} + (memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id) + VALUES ${values.join(", ")} + `); + this.requestIndexSync(); + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + this.assertVectorDimension(query, "Query"); + + if (this.queryType === "HYBRID") { + throw new Error( + "Databricks HYBRID search requires query_text, but search() only receives query vectors.", + ); + } + + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: this.queryType, + query_vector: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + // ponytail: `text_lemmatized` is synced so BM25 has clean text to score, but this query + // does not scope matching to it -- the `query_columns` parameter that would is Beta and + // gated behind the Full-Text Search public preview. Until it is GA the serialized + // `payload` column is also matchable, which adds noise (hashes, JSON keys) to ranking. + // Upgrade path: add `query_columns: ["text_lemmatized"]` once the preview ships. + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: "FULL_TEXT", + query_text: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async get(vectorId: string): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + LIMIT 1 + `); + const row = rows[0]; + + if (!row) { + return null; + } + + return { + id: String(extractRowValue(row, ["memory_id"]) || vectorId), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + await this.initialize(); + this.assertVectorDimension(vector, "Vector"); + + // Session columns (user_id/agent_id/run_id) are the scope keys that search/list filter on and + // are written once at insert(). Mirror Python's `excluded_keys`: update() must never rewrite + // them from the payload -- a partial payload that omits these keys would otherwise SET them to + // NULL and hide the row from every session-scoped search and list(). + await this.executeSql(` + UPDATE ${this.fullTableName} + SET embedding = ${formatSqlValue(vector)}, + payload = ${formatSqlValue(JSON.stringify(payload || {}))}, + text_lemmatized = ${formatSqlValue(lemmatizedText(payload || {}))} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async delete(vectorId: string): Promise { + await this.initialize(); + + await this.executeSql(` + DELETE FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async deleteCol(): Promise { + await this.initialize(); + + try { + await this.httpClient.delete( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + } + + await this.executeSql(`DROP TABLE IF EXISTS ${this.fullTableName}`); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + // `limit` below is interpolated directly into the SQL string (not a bound parameter, + // and not passed through formatSqlValue()), so a non-integer or non-positive topK must + // be rejected here rather than reaching the query -- otherwise a caller that skips + // TypeScript's compile-time check (e.g. anything passing user input straight through) + // could inject arbitrary SQL via the LIMIT clause. + if (!Number.isSafeInteger(topK) || topK <= 0) { + // isSafeInteger (not isInteger): an unsafe/huge integer like 1e21 stringifies as "1e+21", + // which is meaningless as a LIMIT and would slip past a plain integer check. + throw new Error( + `Databricks vector store: topK must be a positive integer, got ${topK}`, + ); + } + + await this.initialize(); + + // Push the SQL-translatable conjunctive filters (session keys) into a WHERE + // clause so a filtered list does not pull the whole table to the client. + // filterVector below still enforces the complete filter, so this clause is a + // best-effort narrowing: untranslatable filters ($or/$not/metadata) yield an + // empty clause and fall back to a bounded scan (see LIMIT below) + local filtering. + const conjunctiveFilters = collectConjunctiveDatabricksFilters(filters); + const clauses = conjunctiveFilters.map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ); + const whereClause = clauses + .filter((clause): clause is string => Boolean(clause)) + .join(" AND "); + + // The WHERE clause fully expresses `filters` (so filterVector below is a + // no-op) only if there's no $or/$not anywhere AND every entry translated. + const isFullyExpressed = + isFullyConjunctiveDatabricksFilter(filters) && + clauses.every((clause) => clause !== null); + // ponytail: 10k scan ceiling when the filter can't be fully pushed down; paginate if a user hits it. + const limit = isFullyExpressed ? topK : MAX_QUERY_RESULTS; + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + ${whereClause ? `WHERE ${whereClause}` : ""} + LIMIT ${limit} + `); + const results: VectorStoreResult[] = []; + + for (const row of rows) { + const item: DatabricksVector = { + id: String(extractRowValue(row, ["memory_id"])), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + if (!this.filterVector(item, filters)) { + continue; + } + results.push({ + id: item.id, + payload: item.payload, + }); + } + + return [results.slice(0, topK), results.length]; + } + + async getUserId(): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT user_id + FROM ${this.catalog}.${this.schema}.memory_migrations + LIMIT 1 + `); + const existing = rows[0] + ? extractRowValue(rows[0], ["user_id"]) + : undefined; + + if (typeof existing === "string" && existing.length > 0) { + return existing; + } + + const userId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(userId); + return userId; + } + + async setUserId(userId: string): Promise { + await this.initialize(); + + await this.executeSql( + `DELETE FROM ${this.catalog}.${this.schema}.memory_migrations`, + ); + await this.executeSql(` + INSERT INTO ${this.catalog}.${this.schema}.memory_migrations (user_id) + VALUES (${formatSqlValue(userId)}) + `); + } + + private async ensureEndpointExists(): Promise { + try { + await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/endpoints", { + name: this.endpointName, + endpoint_type: this.endpointType, + }); + } + } + + private async ensureIndexExists(): Promise { + try { + await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/indexes", { + name: this.fullIndexName, + endpoint_name: this.endpointName, + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: this.fullTableName, + pipeline_type: this.pipelineType, + // ponytail: `payload` stays synced because query results are hydrated straight + // from it. Drop it here and hydrate by memory_id over SQL if index size bites. + columns_to_sync: + this.endpointType === "STANDARD" + ? [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ] + : undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: this.dimension, + }, + ], + }, + }); + } + } + + private async ensureChangeDataFeedEnabled(): Promise { + if (this.endpointType !== "STANDARD") { + return; + } + + await this.executeSql(` + ALTER TABLE ${this.fullTableName} + SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + } + + private createHttpClient(): AxiosInstance { + const baseURL = `${this.workspaceUrl}/api/2.0/vector-search`; + + if (this.accessToken) { + return axios.create({ + baseURL, + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret when httpClient is not provided.", + ); + } + + const baseClient = axios.create({ baseURL }); + return { + get: async (url: string, config?: Record) => + baseClient.get(url, await this.withOAuthHeaders("GET", url, config)), + post: async (url: string, data?: any, config?: Record) => + baseClient.post( + url, + data, + await this.withOAuthHeaders("POST", url, config), + ), + delete: async (url: string, config?: Record) => + baseClient.delete( + url, + await this.withOAuthHeaders("DELETE", url, config), + ), + } as DatabricksHttpClientLike as AxiosInstance; + } + + private async withOAuthHeaders( + method: "GET" | "POST" | "DELETE", + url: string, + config?: Record, + ): Promise> { + const token = await this.getOAuthAccessToken( + this.getOAuthAuthorizationDetails(method, url), + ); + return { + ...(config || {}), + headers: { + ...(config?.headers || {}), + Authorization: `Bearer ${token}`, + }, + }; + } + + private getOAuthAuthorizationDetails( + method: "GET" | "POST" | "DELETE", + url: string, + ): string | undefined { + if ( + method === "POST" && + (url.endsWith("/query") || url.endsWith("/query-next-page")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "ReadVectorIndex", + ); + } + + if ( + method === "POST" && + (url.endsWith("/sync") || + url.endsWith("/upsert-data") || + url.endsWith("/delete-data")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "WriteVectorIndex", + ); + } + + return undefined; + } + + private async getOAuthAccessToken( + authorizationDetails?: string, + ): Promise { + const cacheKey = authorizationDetails || "__management__"; + const cached = this.oauthTokens.get(cacheKey); + if (cached && Date.now() < cached.expiresAt - 60_000) { + return cached.accessToken; + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires clientId/clientSecret for OAuth token refresh.", + ); + } + + const formData = new URLSearchParams({ + grant_type: "client_credentials", + scope: "all-apis", + }); + if (authorizationDetails) { + formData.set("authorization_details", authorizationDetails); + } + + const response = await axios.post( + `${this.workspaceUrl}/oidc/v1/token`, + formData, + { + auth: { + username: this.clientId, + password: this.clientSecret, + }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + const token = response?.data?.access_token; + if (typeof token !== "string" || token.length === 0) { + throw new Error( + "Databricks OAuth token response did not include access_token.", + ); + } + + const expiresInSeconds = Number(response?.data?.expires_in ?? 3600); + this.oauthTokens.set(cacheKey, { + accessToken: token, + expiresAt: Date.now() + Math.max(1, expiresInSeconds) * 1000, + }); + return token; + } + + private async getSession(): Promise { + if (this.session) { + return this.session; + } + + if (!this._sessionPromise) { + this._sessionPromise = this.openSession(); + } + + try { + this.session = await this._sessionPromise; + } catch (error) { + // The first connection attempt failed (cold/auto-suspended warehouse, or a token that + // expired before first use). This runs before executeSql()'s own try/catch, so drop the + // rejected promise here too -- otherwise the next call replays it forever. + this.resetSession(); + throw error; + } + return this.session; + } + + /** + * `@databricks/sql` is an OPTIONAL peer, and `index.ts` re-exports this module eagerly, so a + * top-level `import` makes `import "mem0ai/oss"` throw MODULE_NOT_FOUND for every user who + * never installed the driver -- including everyone who uses a different vector store. Load it + * on the first SQL call instead, the way milvus.ts and baidu.ts load theirs. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite `require()` in + * the published ESM bundle (`dist/oss/index.mjs`) into a `__require` shim that throws + * `Dynamic require of "..." is not supported`, so every ESM consumer would hit a dead + * provider even with the driver installed. + */ + private async getSqlModule(): Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }> { + if (!this.sqlModulePromise) { + this.sqlModulePromise = import("@databricks/sql").then( + (mod) => + mod as unknown as { DBSQLClient: new () => DatabricksSqlClientLike }, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sqlModulePromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + `Install it with: npm install @databricks/sql (original error: ${detail})`, + ); + }, + ); + } + return this.sqlModulePromise; + } + + private async getSqlClient(): Promise { + if (!this.sqlClient) { + const { DBSQLClient } = await this.getSqlModule(); + this.sqlClient = new DBSQLClient(); + } + return this.sqlClient; + } + + private async openSession(): Promise { + const sqlClient = await this.getSqlClient(); + const connected = await sqlClient.connect(this.buildSqlConnectionOptions()); + return connected.openSession(); + } + + private buildSqlConnectionOptions(): Record { + const base = { + host: this.host, + path: this.httpPath, + } as Record; + + if (this.clientId && this.clientSecret) { + return { + ...base, + authType: "databricks-oauth", + oauthClientId: this.clientId, + oauthClientSecret: this.clientSecret, + }; + } + + if (!this.accessToken) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret for SQL connections.", + ); + } + + return { + ...base, + token: this.accessToken, + }; + } + + /** + * Drop the cached session/session-promise so the next `getSession()` opens a fresh one. + * Called after any SQL failure below -- a stale session (expired token, dropped socket) + * would otherwise keep being handed out and keep failing forever. + */ + private resetSession(): void { + this.session = undefined; + this._sessionPromise = undefined; + } + + private async executeSql( + statement: string, + ): Promise>> { + const session = await this.getSession(); + + let operation; + try { + operation = await session.executeStatement(statement); + } catch (error) { + // ponytail: no retry here -- statement could be a non-idempotent write, so we only + // clear the bad session and let the caller's own retry (if any) reconnect. + this.resetSession(); + throw error; + } + + try { + return await operation.fetchAll(); + } catch (error) { + this.resetSession(); + throw error; + } finally { + if (typeof operation.close === "function") { + await operation.close(); + } + } + } + + /** + * A TRIGGERED pipeline only ingests new rows when it is explicitly synced, so every write + * must request one. Writers fire the sync and return; only readers wait for it to land. + * A sync already in flight absorbs later writes -- deleteAll()'s N sequential deletes + * collapse into far fewer than N pipeline runs. + */ + private requestIndexSync(): void { + if (this.pipelineType !== "TRIGGERED") { + return; + } + + this.indexSyncQueued = true; + if (this.indexSyncRunning) { + // A drain is live and has not yet re-checked the queue, so it will pick this up. + return; + } + this.indexSyncRunning = true; + this.indexSyncWait = this.drainIndexSync(); + } + + private async drainIndexSync(): Promise { + try { + // Re-checking the flag and clearing `indexSyncRunning` both happen with no `await` + // between them, so a write can never land on a drain that has stopped looking. + while (this.indexSyncQueued) { + this.indexSyncQueued = false; + await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/sync`, + ); + await this.waitForIndexReadiness(); + } + this.indexSyncError = null; + } catch (error) { + // Surfaced to the next reader: the writer that scheduled this has already returned. + this.indexSyncError = error; + } finally { + this.indexSyncRunning = false; + } + } + + private async awaitIndexSync(): Promise { + while (this.indexSyncWait) { + const wait = this.indexSyncWait; + await wait; + // Only retire the promise we actually awaited; a newer drain may have replaced it, + // and that one still owes us its rows. Clearing unconditionally would drop it. + if (this.indexSyncWait === wait) { + this.indexSyncWait = null; + } + } + if (this.indexSyncError) { + const error = this.indexSyncError; + this.indexSyncError = null; + throw error; + } + } + + private async waitForEndpointReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + const state = + response?.data?.endpoint_status?.state ?? response?.data?.state; + + if (state === "ONLINE") { + return; + } + + if (typeof state !== "string") { + throw new Error( + "Databricks endpoint status did not report a state during initialization.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks endpoint ${this.endpointName} to become ready.`, + ); + } + + private async waitForIndexReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + const ready = response?.data?.status?.ready; + + if (ready === true) { + return; + } + + if (ready !== false) { + throw new Error( + "Databricks index status did not report a readiness flag after sync.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks index ${this.fullIndexName} to become ready after sync.`, + ); + } + + private shouldPaginateForLocalFiltering(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return false; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return true; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + this.shouldPaginateForLocalFiltering(entry as SearchFilters), + ) + ) { + return true; + } + continue; + } + + if (key === "$or" || key === "$not") { + return true; + } + + const translated = + this.endpointType === "STORAGE_OPTIMIZED" + ? buildStorageOptimizedDatabricksFilterClause(key, value) + : buildSimpleDatabricksFilter(key, value); + + if (!translated) { + return true; + } + } + + return false; + } + + private extractNextPageToken(responseData: any): string | undefined { + const token = + responseData?.next_page_token ?? responseData?.result?.next_page_token; + return typeof token === "string" && token.length > 0 ? token : undefined; + } + + private async queryIndex( + requestBody: Record, + filters: SearchFilters | undefined, + topK: number, + ): Promise { + // Read-after-write: writers only request the sync, the reader is what blocks on it. + // get()/list() skip this -- they read the source Delta table, which is always current. + await this.awaitIndexSync(); + const requiresLocalFilteringPagination = + this.shouldPaginateForLocalFiltering(filters); + const queryResultCap = + requestBody.query_type === "FULL_TEXT" + ? MAX_FULL_TEXT_RESULTS + : MAX_QUERY_RESULTS; + const response = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query`, + { + ...requestBody, + num_results: Math.min( + requiresLocalFilteringPagination + ? queryResultCap + : Math.max(topK, DEFAULT_PAGE_SIZE), + queryResultCap, + ), + }, + ); + + const results = this.normalizeQueryResults( + response.data, + ["memory_id", "payload"], + filters, + ); + if (results.length >= topK) { + return results.slice(0, topK); + } + + let nextPageToken = this.extractNextPageToken(response.data); + while (nextPageToken && results.length < topK) { + const nextPage = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query-next-page`, + { + endpoint_name: this.endpointName, + page_token: nextPageToken, + }, + ); + results.push( + ...this.normalizeQueryResults( + nextPage.data, + ["memory_id", "payload"], + filters, + ), + ); + nextPageToken = this.extractNextPageToken(nextPage.data); + } + + return results.slice(0, topK); + } + + private normalizeQueryResults( + responseData: any, + fallbackColumns: string[], + filters: SearchFilters | undefined, + ): VectorStoreResult[] { + const resultData = responseData?.result || responseData; + const dataArray = Array.isArray(resultData?.data_array) + ? resultData.data_array + : []; + const manifestColumns = Array.isArray(resultData?.manifest?.columns) + ? resultData.manifest.columns.map((column: any) => + typeof column === "string" ? column : column.name, + ) + : fallbackColumns; + const results: VectorStoreResult[] = []; + + for (const row of dataArray) { + const rowDict = this.rowToObject(row, manifestColumns); + const item: DatabricksVector = { + id: String(rowDict.memory_id || rowDict.id || rowDict.vector_id), + payload: this.parsePayload(rowDict.payload), + }; + if (!this.filterVector(item, filters)) { + continue; + } + const rawScore = + rowDict.score ?? + (Array.isArray(row) && row.length > manifestColumns.length + ? row[row.length - 1] + : undefined); + results.push({ + id: item.id, + payload: item.payload, + score: + rawScore === undefined || rawScore === null + ? undefined + : Number(rawScore), + }); + } + + return results; + } + + private rowToObject(row: any, columns: string[]): Record { + if (!Array.isArray(row)) { + return row || {}; + } + + return Object.fromEntries( + columns.map((column, index) => [column, row[index]]), + ); + } + + private parsePayload(rawValue: any): Record { + if (typeof rawValue === "string") { + try { + const parsed = JSON.parse(rawValue); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + return {}; + } + } + + if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) { + return rawValue; + } + + return {}; + } + + private extractSessionValues(payload: Record): { + user_id: any; + agent_id: any; + run_id: any; + } { + return { + user_id: payload.user_id, + agent_id: payload.agent_id, + run_id: payload.run_id, + }; + } + + private assertVectorDimension(vector: number[], label: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${label} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + for (const value of vector) { + if (!Number.isFinite(value)) { + throw new Error( + `${label} values must be finite numbers for Databricks vector search.`, + ); + } + } + } + + private matchFieldCondition( + vector: DatabricksVector, + key: string, + value: any, + ): boolean { + const fieldValue = key === "memory_id" ? vector.id : vector.payload[key]; + + if (typeof value !== "object" || value === null) { + if (value === "*") { + return true; + } + return fieldValue === value; + } + + if (Array.isArray(value)) { + return value.includes(fieldValue); + } + + let sawOperator = false; + + if ("eq" in value) { + sawOperator = true; + if (fieldValue !== value.eq) { + return false; + } + } + if ("ne" in value) { + sawOperator = true; + if (fieldValue === value.ne) { + return false; + } + } + if ("gt" in value) { + sawOperator = true; + if (!(fieldValue > value.gt)) { + return false; + } + } + if ("gte" in value) { + sawOperator = true; + if (!(fieldValue >= value.gte)) { + return false; + } + } + if ("lt" in value) { + sawOperator = true; + if (!(fieldValue < value.lt)) { + return false; + } + } + if ("lte" in value) { + sawOperator = true; + if (!(fieldValue <= value.lte)) { + return false; + } + } + if ("in" in value) { + sawOperator = true; + if (!Array.isArray(value.in) || !value.in.includes(fieldValue)) { + return false; + } + } + if ("nin" in value) { + sawOperator = true; + if (Array.isArray(value.nin) && value.nin.includes(fieldValue)) { + return false; + } + } + if ("contains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.includes(value.contains) + ) { + return false; + } + } + if ("icontains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.toLowerCase().includes(value.icontains.toLowerCase()) + ) { + return false; + } + } + + return sawOperator ? true : fieldValue === value; + } + + private filterVector( + vector: DatabricksVector, + filters?: SearchFilters, + ): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + const keyMap: Record = { + $and: "AND", + $or: "OR", + $not: "NOT", + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(filters)) { + const normalizedKey = keyMap[key] || key; + if (!(normalizedKey in normalized)) { + normalized[normalizedKey] = value; + } + } + + for (const [key, value] of Object.entries(normalized)) { + if (key === "AND") { + if (!Array.isArray(value)) { + throw new Error( + `AND filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "OR") { + if (!Array.isArray(value)) { + throw new Error( + `OR filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.some((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "NOT") { + if (!Array.isArray(value)) { + throw new Error( + `NOT filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every( + (entry: SearchFilters) => !this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (!this.matchFieldCondition(vector, key, value)) { + return false; + } + } + + return true; + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts index 9349af63f..79f6e5c9a 100644 --- a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts +++ b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts @@ -1,10 +1,10 @@ -import { - ExecuteQueryCommand, - NeptuneGraphClient, -} from "@aws-sdk/client-neptune-graph"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +/** + * The `@aws-sdk/client-neptune-graph` dependency is loaded on first use via dynamic + * `import()` so the package stays optional (mirrors `aws_bedrock.ts`). + */ interface NeptuneAnalyticsConfig extends VectorStoreConfig { graphIdentifier?: string; endpoint?: string; @@ -14,7 +14,14 @@ interface NeptuneAnalyticsConfig extends VectorStoreConfig { } interface NeptuneGraphClientLike { - send(command: ExecuteQueryCommand): Promise; + send(command: any): Promise; +} + +interface NeptuneSDK { + NeptuneGraphClient: new ( + config: Record, + ) => NeptuneGraphClientLike; + ExecuteQueryCommand: new (input: Record) => any; } interface NeptuneExecuteQueryOutput { @@ -33,7 +40,10 @@ interface WhereClauseResult { } export class NeptuneAnalyticsVectorStore implements VectorStore { - private readonly client: NeptuneGraphClientLike; + private clientConfig: Record; + private clientOverride?: NeptuneGraphClientLike; + private sdkPromise?: Promise; + private clientPromise?: Promise; private readonly graphIdentifier: string; private readonly collectionName: string; private readonly collectionLabel: string; @@ -54,8 +64,8 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { this.userLabelExpr = this.escapeLabel(this.userLabel); this.userNodeId = "mem0-user"; this.dimension = config.dimension || 1536; - this.client = - config.client || new NeptuneGraphClient(this.buildClientConfig(config)); + this.clientConfig = this.buildClientConfig(config); + this.clientOverride = config.client; void this.initialize().catch(console.error); } @@ -171,6 +181,18 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { const hasPayload = !!payload && Object.keys(payload).length > 0; const hasVector = vector.length > 0; + // ponytail: a combined update writes the payload before the embedding, and Neptune's vector + // index isn't transactional -- if the upsert below fails, the new payload would otherwise be + // left committed against the stale embedding (searches would match the old vector but return + // the new metadata). Capture the prior node so a failed upsert can be restored; this is + // best-effort compensation, not a rollback. Only needed when both writes happen -- a + // payload-only or vector-only update can't desync. + // The restore assumes a single writer per vectorId -- concurrent updates to the same node can + // interleave and clobber each other's compensation. AWS advises against concurrent same-vertex + // writes to the Neptune Analytics vector index for exactly this reason. + const priorResult = + hasPayload && hasVector ? await this.get(vectorId) : null; + if (hasPayload) { const properties = this.buildStoredPayload(payload); await this.executeQuery( @@ -187,20 +209,45 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { } if (hasVector) { - const updateResults = await this.executeQuery( - ` - MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) - WITH n, $embedding AS embedding - CALL neptune.algo.vectors.upsert(n, embedding) - YIELD success - RETURN success - `, - { - vectorId, - embedding: vector, - }, - ); - this.assertSuccessfulResults(updateResults, "Update"); + try { + const updateResults = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + WITH n, $embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `, + { + vectorId, + embedding: vector, + }, + ); + this.assertSuccessfulResults(updateResults, "Update"); + } catch (error) { + if (priorResult) { + try { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties: priorResult.payload, + }, + ); + } catch (restoreError) { + // Do not mask the original failure with a compensation failure. + console.error( + "Neptune Analytics: failed to restore prior payload after a failed update upsert", + restoreError, + ); + } + } + throw error; + } } } @@ -985,12 +1032,58 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { } } + /** + * Load the optional AWS SDK on first use. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite + * `require()` in the published ESM bundle (`dist/oss/index.mjs`) into a + * `__require` shim that throws `Dynamic require of "..." is not supported`, + * so every ESM consumer would hit a dead provider even with the SDK installed. + */ + private async getSDK(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-neptune-graph").then( + (sdk) => sdk as unknown as NeptuneSDK, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sdkPromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@aws-sdk/client-neptune-graph' package is required to use the Neptune Analytics vector store. " + + `Install it with: npm install @aws-sdk/client-neptune-graph (original error: ${detail})`, + ); + }, + ); + } + return this.sdkPromise; + } + + /** Memoized Neptune client; an injected `config.client` short-circuits the SDK. */ + private async getClient(): Promise { + if (this.clientOverride) return this.clientOverride; + if (!this.clientPromise) { + this.clientPromise = this.getSDK() + .then( + ({ NeptuneGraphClient }) => new NeptuneGraphClient(this.clientConfig), + ) + .catch((err) => { + // Mirror getSDK(): drop the rejected promise so a later call retries rather than + // replaying a cached rejection forever (a rejected promise is still truthy, so the + // `!this.clientPromise` guard above would otherwise never re-enter). + this.clientPromise = undefined; + throw err; + }); + } + return this.clientPromise; + } + private async executeQuery( queryString: string, parameters: Record = {}, ): Promise { - const response = await this.client.send( - new ExecuteQueryCommand({ + const [client, sdk] = await Promise.all([this.getClient(), this.getSDK()]); + const response = await client.send( + new sdk.ExecuteQueryCommand({ graphIdentifier: this.graphIdentifier, language: "OPEN_CYPHER", queryString, diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 331384465..7c1856ad8 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -522,6 +522,34 @@ describe("ConfigManager", () => { expect(cfg.embedder.config).not.toHaveProperty("embedding_dims"); }); }); + + describe("mergeConfig - vector store provider normalization", () => { + const baseLlm = { provider: "openai", config: { apiKey: "test-key" } }; + const baseEmbedder = { provider: "openai", config: { apiKey: "test-key" } }; + + it.each(["Memory", "DATABRICKS", "QdRaNt"])( + "lowercases the vector store provider %p", + (provider) => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider, config: { collectionName: "test" } }, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe(provider.toLowerCase()); + }, + ); + + it("still falls back to the default provider when none is given", () => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { config: { collectionName: "test" } } as any, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe("memory"); + }); + }); }); // ───────────────────────────────────────────────────────────────────────── diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index 0a3994303..706c178b3 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -183,6 +183,11 @@ jest.mock("../src/vector_stores/pgvector", () => ({ .fn() .mockImplementation((config) => ({ type: "pgvector", config })), })); +jest.mock("../src/vector_stores/databricks", () => ({ + DatabricksVectorStore: jest + .fn() + .mockImplementation((config) => ({ type: "databricks", config })), +})); jest.mock("../src/vector_stores/neptune_analytics", () => ({ NeptuneAnalyticsVectorStore: jest.fn().mockImplementation((config) => ({ type: "neptune-analytics", @@ -346,6 +351,7 @@ describe("VectorStoreFactory", () => { ["vectorize"], ["azure-ai-search"], ["pgvector"], + ["databricks"], ["neptune"], ["neptune-analytics"], ["upstash_vector"], diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index 6ab87294f..ee2438c5e 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -719,6 +719,1511 @@ describe("AzureAISearch – backward compat with mocked client", () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +// 6. Databricks — mock SQL + REST clients, test interface + idempotent init +// ─────────────────────────────────────────────────────────────────────────── +describe("Databricks – backward compat with mocked clients", () => { + let DatabricksVectorStore: any; + + beforeEach(() => { + jest.resetModules(); + + jest.doMock("@databricks/sql", () => { + let migrationUserId: string | undefined; + + const executeStatement = jest + .fn() + .mockImplementation(async (sql: string) => { + const normalized = sql.replace(/\s+/g, " ").trim(); + + if ( + normalized.startsWith("CREATE SCHEMA IF NOT EXISTS") || + normalized.startsWith("CREATE TABLE IF NOT EXISTS") || + normalized.startsWith("ALTER TABLE main.default.memories") + ) { + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "SELECT user_id FROM main.default.memory_migrations LIMIT 1", + ) + ) { + return { + fetchAll: jest + .fn() + .mockResolvedValue( + migrationUserId ? [{ user_id: migrationUserId }] : [], + ), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith("DELETE FROM main.default.memory_migrations") + ) { + migrationUserId = undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "INSERT INTO main.default.memory_migrations (user_id)", + ) + ) { + const match = normalized.match(/VALUES \('([^']*)'\)/); + migrationUserId = match ? match[1] : undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + }); + + const session = { + executeStatement, + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + + client.connect.mockResolvedValue(client); + + return { + DBSQLClient: jest.fn().mockImplementation(() => client), + __mockClient: client, + __mockSession: session, + }; + }); + + jest.doMock("axios", () => { + let endpointExists = false; + let endpointPendingReadyResponses = 0; + let indexExists = false; + let indexPendingReadyResponses = 0; + let syncPendingReadyResponses = 0; + let indexNeverReady = false; + let pagedQueryResponses: any[] = []; + + const defaultQueryResponse = { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.75], + ], + }, + }; + + const httpClient = { + get: jest.fn().mockImplementation(async (url: string) => { + if (url === "/endpoints/mem0_vector_search") { + if (!endpointExists) { + const error: any = new Error("missing endpoint"); + error.response = { status: 404 }; + throw error; + } + if (endpointPendingReadyResponses > 0) { + endpointPendingReadyResponses -= 1; + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "PROVISIONING" }, + }, + }; + } + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "ONLINE" }, + }, + }; + } + + if (url === "/indexes/main.default.memories") { + if (!indexExists) { + const error: any = new Error("missing index"); + error.response = { status: 404 }; + throw error; + } + if (indexNeverReady) { + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (indexPendingReadyResponses > 0) { + indexPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (syncPendingReadyResponses > 0) { + syncPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + return { + data: { + name: "main.default.memories", + status: { ready: true }, + }, + }; + } + + throw new Error(`Unexpected Databricks GET ${url}`); + }), + post: jest.fn().mockImplementation(async (url: string, body: any) => { + if (url === "/endpoints") { + endpointExists = true; + endpointPendingReadyResponses = 1; + return { data: { endpoint: body.name } }; + } + + if (url === "/indexes") { + indexExists = true; + indexPendingReadyResponses = 1; + return { data: { index: body.name } }; + } + + if (url === "/indexes/main.default.memories/sync") { + syncPendingReadyResponses = 1; + return { data: { status: "queued" } }; + } + + if (url === "/indexes/main.default.memories/query") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + if (url === "/indexes/main.default.memories/query-next-page") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + throw new Error(`Unexpected Databricks POST ${url}`); + }), + delete: jest.fn().mockImplementation(async (url: string) => { + if (url === "/indexes/main.default.memories") { + indexExists = false; + return { data: {} }; + } + throw new Error(`Unexpected Databricks DELETE ${url}`); + }), + }; + + const create = jest.fn().mockReturnValue(httpClient); + const authPost = jest.fn().mockResolvedValue({ + data: { access_token: "oauth-token", expires_in: 3600 }, + }); + + return { + __esModule: true, + default: { create, post: authPost }, + create, + post: authPost, + __mockHttpClient: httpClient, + __setPagedQueryResponses: (responses: any[]) => { + pagedQueryResponses = responses.map((response) => + JSON.parse(JSON.stringify(response)), + ); + }, + __setIndexNeverReady: (value: boolean) => { + indexNeverReady = value; + }, + __mockAuthPost: authPost, + }; + }); + + DatabricksVectorStore = + require("../src/vector_stores/databricks").DatabricksVectorStore; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("implements full VectorStore interface", () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + expect(typeof store.insert).toBe("function"); + expect(typeof store.search).toBe("function"); + expect(typeof store.get).toBe("function"); + expect(typeof store.update).toBe("function"); + expect(typeof store.delete).toBe("function"); + expect(typeof store.deleteCol).toBe("function"); + expect(typeof store.list).toBe("function"); + expect(typeof store.getUserId).toBe("function"); + expect(typeof store.setUserId).toBe("function"); + expect(typeof store.initialize).toBe("function"); + }); + + it("initialize() is idempotent (same promise returned)", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + await Promise.all([p1, p2, p3]); + + const clientInstance = databricksSql.DBSQLClient.mock.results[0].value; + const session = databricksSql.__mockSession; + const httpClient = axiosModule.__mockHttpClient; + expect(clientInstance.connect).toHaveBeenCalledTimes(1); + expect(clientInstance.openSession).toHaveBeenCalledTimes(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/endpoints/mem0_vector_search", + ).length, + ).toBeGreaterThan(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(1); + expect(httpClient.post).toHaveBeenCalledWith("/endpoints", { + name: "mem0_vector_search", + endpoint_type: "STANDARD", + }); + expect(httpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ], + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 3, + }, + ], + }, + }); + expect( + session.executeStatement.mock.calls.some( + ([sql]: [string]) => + sql.includes("ALTER TABLE main.default.memories") && + sql.includes("delta.enableChangeDataFeed"), + ), + ).toBe(true); + }); + + it("retries loading @databricks/sql after a failed dynamic import instead of caching the rejection", async () => { + let shouldFail = true; + jest.doMock("@databricks/sql", () => { + if (shouldFail) { + throw new Error("Cannot find module '@databricks/sql'"); + } + const session = { + executeStatement: jest.fn().mockResolvedValue({ + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }), + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + client.connect.mockResolvedValue(client); + return { DBSQLClient: jest.fn().mockImplementation(() => client) }; + }); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await expect((store as any).getSqlModule()).rejects.toThrow( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + "Install it with: npm install @databricks/sql (original error: Cannot find module '@databricks/sql')", + ); + + shouldFail = false; + const sqlModule = await (store as any).getSqlModule(); + expect(typeof sqlModule.DBSQLClient).toBe("function"); + }); + + it("executeSql(): reconnects on the next call after a session failure instead of reusing a dead session", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + const openSessionSpy = jest.spyOn(store as any, "openSession"); + + session.executeStatement.mockRejectedValueOnce( + new Error("session expired"), + ); + await expect((store as any).executeSql("SELECT 1")).rejects.toThrow( + "session expired", + ); + expect(openSessionSpy).not.toHaveBeenCalled(); + + await (store as any).executeSql("SELECT 1"); + expect(openSessionSpy).toHaveBeenCalledTimes(1); + }); + + it("getSession(): retries after a failed connection attempt instead of caching the rejection", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // Simulate the warehouse dropping so the next getSession() must reconnect from scratch + // (this clears both the cached session and any in-flight session promise). + (store as any).resetSession(); + + let attempts = 0; + jest.spyOn(store as any, "openSession").mockImplementation(async () => { + attempts++; + if (attempts === 1) { + throw new Error("warehouse cold start"); + } + return databricksSql.__mockSession; + }); + + // First connection attempt fails (e.g. a cold/auto-suspended warehouse at startup)... + await expect((store as any).getSession()).rejects.toThrow( + "warehouse cold start", + ); + + // ...the next call must open a fresh session rather than replay the rejected promise. + // Without getSession()'s catch-and-reset, openSession would never be called a second time. + const session = await (store as any).getSession(); + expect(session).toBe(databricksSql.__mockSession); + expect(attempts).toBe(2); + }); + + it("supports service-principal credentials for REST and SQL setup", async () => { + const axiosModule = require("axios"); + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + clientId: "client-id", + clientSecret: "client-secret", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + await store.insert([[1, 0, 0]], ["id-1"], [{ user_id: "u1" }]); + await store.search([1, 0, 0], 1); + + expect(axiosModule.__mockAuthPost).toHaveBeenCalledWith( + "https://workspace.databricks.com/oidc/v1/token", + expect.any(URLSearchParams), + expect.objectContaining({ + auth: { + username: "client-id", + password: "client-secret", + }, + }), + ); + const authDetails = axiosModule.__mockAuthPost.mock.calls.map( + ([, params]: [string, URLSearchParams]) => + params.get("authorization_details"), + ); + const readDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "ReadVectorIndex", + }, + ]); + const writeDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "WriteVectorIndex", + }, + ]); + + expect(authDetails).toContain(null); + expect(authDetails).toContain(readDetails); + expect(authDetails).toContain(writeDetails); + expect(databricksSql.__mockClient.connect).toHaveBeenCalledWith( + expect.objectContaining({ + authType: "databricks-oauth", + oauthClientId: "client-id", + oauthClientSecret: "client-secret", + }), + ); + }); + + it("shapes Databricks SQL writes, syncs triggered indexes, and normalizes search results", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + // Non-zero: a 0ms poll skips the sleep entirely, so a sync would drain within the + // microtask queue and never overlap the next write. 1ms models a real readiness wait. + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + + const session = databricksSql.__mockSession; + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO main.default.memories"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("'id-1'"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("array(1, 0, 0)"), + ); + await store.update("id-1", [0, 1, 0], { user_id: "u1", topic: "beta" }); + await store.delete("id-1"); + + const results = await store.search([1, 0, 0], 5, { + user_id: "u1", + topic: "alpha", + }); + const httpClient = axiosModule.__mockHttpClient; + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + // insert/update/delete each request a sync, but writes that land while one is already + // in flight coalesce into it -- so three writes never cost three pipeline runs. + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(3); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(4); + expect(httpClient.post).toHaveBeenCalledWith( + "/indexes/main.default.memories/query", + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters_json: JSON.stringify({ user_id: "u1" }), + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("keeps metadata-only filters local for standard endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("applies multiple local metadata operators on the same field", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + [ + "id-1", + JSON.stringify({ user_id: "u1", importance: 1.0, topic: "high" }), + 0.99, + ], + [ + "id-2", + JSON.stringify({ + user_id: "u1", + importance: 0.75, + topic: "within-range", + }), + 0.98, + ], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { + importance: { gte: 0.5, lte: 0.9 }, + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-2", + payload: { user_id: "u1", importance: 0.75, topic: "within-range" }, + score: 0.98, + }, + ]); + }); + + it("paginates when local-only filters need matches beyond the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1, { topic: "alpha" }); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("paginates across Databricks result pages when topK exceeds the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1001); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 1001, + }), + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.99, + }, + { + id: "id-2", + payload: { user_id: "u2", topic: "beta" }, + score: 0.98, + }, + ]); + }); + + it("caps full-text local-fallback requests at Databricks' 200-result limit", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.keywordSearch("alpha", 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 200, + query_type: "FULL_TEXT", + query_text: "alpha", + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("uses SQL-like filters for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + const results = await store.search(query, 5, { + user_id: "u1", + topic: "alpha", + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters: "user_id = 'u1'", + query_type: "ANN", + query_vector: query, + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("translates storage-optimized array shorthand filters into IN clauses", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + await store.search(query, 5, { + user_id: ["u1", "u2"], + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters: "user_id IN ('u1', 'u2')", + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + }); + + it("falls back to local filtering for logical operators", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const orResults = await store.search([1, 0, 0], 5, { + $or: [{ user_id: "u1" }, { topic: "beta" }], + }); + const notResults = await store.search([1, 0, 0], 5, { + $not: [{ user_id: "u2" }], + }); + const queryCalls = axiosModule.__mockHttpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCalls).toHaveLength(2); + expect(queryCalls[0][1]).not.toHaveProperty("filters_json"); + expect(queryCalls[1][1]).not.toHaveProperty("filters_json"); + expect(orResults.map((result: any) => result.id)).toEqual(["id-1", "id-2"]); + expect(notResults.map((result: any) => result.id)).toEqual(["id-1"]); + }); + + it("keeps memory_id filters aligned between Databricks and local fallback", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { memory_id: "id-1" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters_json: JSON.stringify({ memory_id: "id-1" }), + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("omits columns_to_sync for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + + await store.initialize(); + + expect(axiosModule.__mockHttpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 16, + }, + ], + }, + }); + }); + + it("rejects invalid storage-optimized embedding dimensions", () => { + expect( + () => + new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + endpointType: "STORAGE_OPTIMIZED", + }), + ).toThrow("require dimensions divisible by 16"); + }); + + it("rejects HYBRID vector search without query text", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + queryType: "HYBRID", + syncPollIntervalMs: 0, + }); + + await expect(store.search([1, 0, 0], 5)).rejects.toThrow( + "Databricks HYBRID search requires query_text", + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ), + ).toBeUndefined(); + }); + + it("roundtrips migration user ids", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + }); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + }); + + it("escapes backslashes and quotes in SQL string literals (injection-safe)", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // The id/user_id carry a single quote and a backslash. Databricks SQL treats + // "\" as an escape char, so without doubling it, "\'" would break out of the + // literal (injection) and any backslash would be dropped on write. + await store.insert([[1, 0, 0]], ["a'b\\c"], [{ user_id: "x'y\\z" }]); + + const session = databricksSql.__mockSession; + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("INSERT INTO main.default.memories")); + + expect(insertSql).toBeDefined(); + // Backslash doubled AND quote doubled: 'a''b\\c' + expect(insertSql).toContain("'a''b\\\\c'"); + // The un-escaped single-backslash form (the breakout vector) must be absent. + expect(insertSql).not.toContain("'a''b\\c'"); + }); + + it("pushes session filters into the SQL WHERE clause when listing", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ user_id: "u1" }, 10); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).toContain("WHERE user_id = 'u1'"); + }); + + it("list(): pushes null-safe ne/nin predicates into the WHERE clause", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ agent_id: { ne: "a2" } }, 10); + const neSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(neSql).toBeDefined(); + expect(neSql).toContain("(agent_id IS NULL OR agent_id != 'a2')"); + expect(neSql).not.toContain("WHERE agent_id != 'a2'"); + + session.executeStatement.mockClear(); + await store.list({ agent_id: { nin: ["a2", "a3"] } }, 10); + const ninSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(ninSql).toBeDefined(); + expect(ninSql).toContain( + "(agent_id IS NULL OR agent_id NOT IN ('a2', 'a3'))", + ); + expect(ninSql).not.toContain("WHERE agent_id NOT IN ('a2', 'a3')"); + }); + + it("list(): bounds the SQL scan with LIMIT topK when the filter fully pushes down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }); + const defaultTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(defaultTopKSql).toContain("LIMIT 100"); + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }, 7); + const customTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(customTopKSql).toContain("LIMIT 7"); + }); + + it("list(): rejects a topK that isn't a positive integer before it reaches SQL", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + for (const badTopK of [ + 0, + -1, + 2.5, + NaN, + "10; DROP TABLE t" as unknown as number, + ]) { + await expect(store.list({ user_id: "u1" }, badTopK)).rejects.toThrow( + `Databricks vector store: topK must be a positive integer, got ${badTopK}`, + ); + } + }); + + it("list(): falls back to the 10k scan ceiling (no WHERE) when $or can't be pushed down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ $or: [{ user_id: "u1" }, { topic: "beta" }] }); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).not.toContain("WHERE"); + expect(listSql).toContain("LIMIT 10000"); + }); + + it("provisions a dedicated text_lemmatized column for BM25 keyword search", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + + const session = databricksSql.__mockSession; + const createSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("CREATE TABLE IF NOT EXISTS main.default.memories"), + ); + + expect(createSql).toBeDefined(); + expect(createSql).toContain("text_lemmatized STRING"); + }); + + it("writes lemmatized text on insert and refreshes it on update", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ data: "running shoes", textLemmatized: "run shoe" }], + ); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain( + "(memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id)", + ); + expect(insertSql).toContain("'run shoe'"); + + session.executeStatement.mockClear(); + await store.update("id-1", [0, 1, 0], { + data: "walking boots", + textLemmatized: "walk boot", + }); + + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("UPDATE")); + + expect(updateSql).toContain("text_lemmatized = 'walk boot'"); + }); + + it("falls back to payload.data when textLemmatized is absent", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert([[1, 0, 0]], ["id-1"], [{ data: "plain text" }]); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain("'plain text'"); + }); + + it("coalesces triggered-index syncs across sequential deletes", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + // deleteAll() awaits each deleteMemory in turn -- exactly this shape. + for (let index = 0; index < 8; index += 1) { + await store.delete(`id-${index}`); + } + await store.search([1, 0, 0], 5); + + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(8); + }); + + it("starts one sync for concurrent writers, and still drains the second write", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + const syncPosts = () => + httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ).length; + + await Promise.all([ + store.insert([[1, 0, 0]], ["id-a"], [{ data: "alpha" }]), + store.insert([[0, 1, 0]], ["id-b"], [{ data: "beta" }]), + ]); + + // Both writers ran before any sync settled. Exactly one drain may own the pipeline -- + // if both had raced past the guard they would each have POSTed their own sync. + expect(syncPosts()).toBe(1); + + // The second writer's rows are not lost: the live drain re-checks the queue and syncs again. + await store.search([1, 0, 0], 5); + expect(syncPosts()).toBe(2); + }); + + it("does not block writes on index readiness, and surfaces sync failures on the next read", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + syncTimeoutMs: 10, + }); + + await store.initialize(); + // Every readiness poll from here on reports not-ready, so the sync can only time out. + axiosModule.__setIndexNeverReady(true); + + // The write still resolves: it schedules the sync, it does not wait for it. + await expect( + store.insert([[1, 0, 0]], ["id-1"], [{ data: "alpha" }]), + ).resolves.toBeUndefined(); + + // The reader is what pays -- and inherits the deferred failure. + await expect(store.search([1, 0, 0], 5)).rejects.toThrow(); + + // The error is consumed once, not latched forever. + axiosModule.__setIndexNeverReady(false); + await expect(store.search([1, 0, 0], 5)).resolves.toBeDefined(); + }); + + it("update() does not null out session columns on a partial payload", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + // Partial payload -- omits user_id/agent_id/run_id, the way a caller updating just one + // field would. Mirrors Python's `excluded_keys`: these must survive untouched. + await store.update("id-1", [0, 1, 0], { topic: "beta" }); + + const session = databricksSql.__mockSession; + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("UPDATE")); + + expect(updateSql).toBeDefined(); + expect(updateSql).not.toMatch(/\buser_id\s*=/); + expect(updateSql).not.toMatch(/\bagent_id\s*=/); + expect(updateSql).not.toMatch(/\brun_id\s*=/); + expect(updateSql).toMatch(/embedding\s*=/); + expect(updateSql).toMatch(/payload\s*=/); + }); + + it("list() rejects an unsafe-integer topK that would slip past Number.isInteger", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + // 1e21 is Number.isInteger()-true but not representable exactly, and stringifies as + // "1e+21" -- meaningless (and unguarded) as a SQL LIMIT. + await expect(store.list({ user_id: "u1" }, 1e21)).rejects.toThrow( + /topK must be a positive integer/, + ); + // Sanity: a normal topK is still accepted (does not throw). + await expect(store.list({ user_id: "u1" }, 10)).resolves.toBeDefined(); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // Cassandra — mock client, test interface + idempotent init // ─────────────────────────────────────────────────────────────────────────── @@ -1533,8 +3038,39 @@ describe("S3 Vectors – backward compat with mocked client", () => { // 6. Neptune Analytics — mock NeptuneGraph client, test interface + init // ─────────────────────────────────────────────────────────────────────────── describe("Neptune Analytics – backward compat with mocked client", () => { + // `@aws-sdk/client-neptune-graph` is an optional peer loaded via dynamic `import()` + // (see neptune_analytics.ts). Register a default virtual mock so every test that + // exercises `executeQuery()` -- even ones that inject their own `config.client` -- + // can resolve `sdk.ExecuteQueryCommand` without the real package being installed. + // Tests that care about the AWS client constructor itself override this locally + // with their own `jest.doMock(..., { virtual: true })` before requiring the module. + beforeEach(() => { + jest.resetModules(); + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + throw new Error( + "Neptune Analytics tests must inject a mock `client`; the default virtual SDK has no server to talk to.", + ); + } + }, + }), + { virtual: true }, + ); + }); + afterEach(() => { - jest.dontMock("@aws-sdk/client-neptune-graph"); jest.resetModules(); }); @@ -2034,29 +3570,34 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(mockClient.send).not.toHaveBeenCalled(); }); - it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", () => { + it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", async () => { jest.resetModules(); - const neptuneGraphClient = jest.fn().mockReturnValue({ - send: jest.fn(), - }); + const send = jest + .fn() + .mockResolvedValue(createMockResponse({ results: [] })); + const neptuneGraphClient = jest.fn().mockReturnValue({ send }); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: neptuneGraphClient, - })); + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, } = require("../src/vector_stores/neptune_analytics"); - new NeptuneAnalyticsVectorStore({ + const store = new NeptuneAnalyticsVectorStore({ graphIdentifier: "g-1234567890", endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", collectionName: "test", @@ -2066,6 +3607,9 @@ describe("Neptune Analytics – backward compat with mocked client", () => { maxAttempts: 3, }); + // The client is now constructed lazily on first use, not in the constructor. + await store.search([1, 2, 3], 1); + expect(neptuneGraphClient).toHaveBeenCalledWith({ endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", maxAttempts: 3, @@ -2077,18 +3621,22 @@ describe("Neptune Analytics – backward compat with mocked client", () => { it("rejects HTTPS endpoints without an explicit graphIdentifier", () => { jest.resetModules(); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: jest.fn().mockReturnValue({ - send: jest.fn(), + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: jest.fn().mockReturnValue({ + send: jest.fn(), + }), }), - })); + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, @@ -2114,16 +3662,20 @@ describe("Neptune Analytics – backward compat with mocked client", () => { .mockResolvedValue(createMockResponse({ results: [] })); const neptuneGraphClient = jest.fn().mockReturnValue({ send }); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: neptuneGraphClient, - })); + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, @@ -2144,6 +3696,109 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(send.mock.calls[0][0].input.graphIdentifier).toBe("g-1234567890"); }); + it("does not construct the Neptune client or load the SDK until the first query", async () => { + jest.resetModules(); + + let clientConstructions = 0; + let sdkLoads = 0; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + sdkLoads++; + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) { + clientConstructions++; + } + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(clientConstructions).toBe(0); + expect(sdkLoads).toBe(0); + + await store.search([1, 2, 3], 1); + + expect(sdkLoads).toBe(1); + expect(clientConstructions).toBe(1); + }); + + it("retries constructing the Neptune client after a failed SDK load instead of caching the rejection", async () => { + jest.resetModules(); + + let shouldFail = true; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + if (shouldFail) { + throw new Error("Cannot find module '@aws-sdk/client-neptune-graph'"); + } + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + // First use fails because the optional SDK can't be loaded... + await expect((store as any).getClient()).rejects.toThrow( + "The '@aws-sdk/client-neptune-graph' package is required", + ); + + // ...but a later call must retry rather than replay the cached rejection. Without + // getClient()'s catch-and-reset this second call would throw the same stale error. + shouldFail = false; + const client = await (store as any).getClient(); + expect(client).toBeDefined(); + expect(typeof client.send).toBe("function"); + }); + it("shapes Neptune write requests and normalizes search results", async () => { const { NeptuneAnalyticsVectorStore, @@ -2592,7 +4247,7 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(await freshStore.getUserId()).toBe("persisted-user"); }); - it("throws when Neptune rejects an update upsert", async () => { + it("rolls back the payload when Neptune rejects an update upsert", async () => { const { NeptuneAnalyticsVectorStore, } = require("../src/vector_stores/neptune_analytics"); @@ -2613,15 +4268,15 @@ describe("Neptune Analytics – backward compat with mocked client", () => { store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), ).rejects.toThrow("Update failed in Neptune Analytics"); - // The payload write runs before the vector upsert, so it is already - // durable by the time the vector step rejects — the caller's new - // metadata must not be silently dropped just because the embedding - // failed to update afterward. + // Neptune's vector index isn't transactional: the payload write already lands by the + // time the vector step rejects. Without compensation the new metadata would be stuck + // pointing at the stale embedding, so a failed update rolls the payload back to what + // it was before the call instead of leaving the two desynced. const afterFailedUpsert = await store.get("id-1"); expect(afterFailedUpsert).not.toBeNull(); expect(afterFailedUpsert!.payload).toEqual( expect.objectContaining({ - data: "beta", + data: "alpha", user_id: "u1", }), ); @@ -2873,7 +4528,7 @@ describe("Vectorize – backward compat with mocked client", () => { }); // ─────────────────────────────────────────────────────────────────────────── -// 7. LangchainVectorStore — mock Langchain client, verify no-op init +// 8. LangchainVectorStore — mock Langchain client, verify no-op init // ─────────────────────────────────────────────────────────────────────────── describe("LangchainVectorStore – backward compat", () => { it("implements full VectorStore interface", () => { @@ -3515,6 +5170,92 @@ describe("Memory class – backward compat with all providers", () => { expect(mockVStore2.initialize).toHaveBeenCalled(); }); + it("derives a separate Databricks entity table when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Databricks", + config: { + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + collectionName: "memories", + tableName: "memory_rows", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "databricks", + expect.objectContaining({ + collectionName: "memories_entities", + tableName: "memory_rows_entities", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + + // optional-peers.test.ts proves no optional peer is imported at module scope. This asserts the + // other half for Databricks: the driver is still loaded lazily (memoized dynamic import, with + // retry-on-failure via getSqlModule()) rather than dropped entirely or made static/eager. + it("still loads the optional @databricks/sql driver lazily", () => { + const source = fs.readFileSync( + path.join(__dirname, "../src/vector_stores/databricks.ts"), + "utf8", + ); + expect(source).toContain('import("@databricks/sql")'); + }); + + it("sends file-based entities to their own DB when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Memory", + config: { + collectionName: "memories", + dbPath: "/tmp/mem0-casing.db", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + // Un-normalized, the `=== "memory"` check misses, dbPath is left alone, and entities land + // in the very file they are supposed to be split out of. + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "memory", + expect.objectContaining({ + collectionName: "memories_entities", + dbPath: "/tmp/mem0-casing_entities.db", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + it("propagates init error to public methods", async () => { const failingEmbedder = { embed: jest.fn().mockRejectedValue(new Error("Embedder unreachable")), diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 777308270..a98af7eb4 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -32,6 +32,7 @@ const external = [ "@azure/identity", "cloudflare", "@cloudflare/workers-types", + "@databricks/sql", "@langchain/core", "fastembed", "compromise", diff --git a/mem0/vector_stores/neptune_analytics.py b/mem0/vector_stores/neptune_analytics.py index 905b5de57..4c9868beb 100644 --- a/mem0/vector_stores/neptune_analytics.py +++ b/mem0/vector_stores/neptune_analytics.py @@ -232,6 +232,20 @@ class NeptuneAnalyticsVector(VectorStoreBase): vector (Optional[List[float]]): New embedding vector. payload (Optional[Dict]): New metadata to replace existing payload. """ + # ponytail: a combined update writes the payload before the embedding, and Neptune's + # vector index isn't transactional -- if the upsert below fails, the new payload would + # otherwise be left committed against the stale embedding. Capture the prior properties + # so a failed upsert can be restored; this is best-effort compensation, not a rollback. + # Only needed when both writes happen -- a payload-only or vector-only update can't desync. + # The restore assumes a single writer per vector_id -- concurrent updates to the same node + # can interleave and clobber each other's compensation. AWS advises against concurrent + # same-vertex writes to the Neptune Analytics vector index for exactly this reason. + prior_properties = None + if payload and vector: + prior = self.get(vector_id) + if prior is not None: + prior_properties = dict(prior.payload or {}) + prior_properties[self._FIELD_LABEL] = self.collection_name if payload: # Replace payload @@ -242,9 +256,9 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - SET n = $properties + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties """ self.execute_query(query_string_embedding, para_payload) @@ -254,14 +268,40 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - WITH $embedding as embedding, n as n - CALL neptune.algo.vectors.upsert(n, embedding) - YIELD success - RETURN success + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + WITH $embedding as embedding, n as n + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success """ - self.execute_query(query_string_embedding, para_embedding) + try: + result = self.execute_query(query_string_embedding, para_embedding) + # A soft {"success": False} row desyncs the payload from the embedding just as + # much as a thrown error, so treat it as a failure and let the rollback below fire. + # Mirrors the TS store's assertSuccessfulResults() check (Python's + # _process_success_message only logs, so it cannot drive the rollback). + for row in result or []: + if "success" in row and row["success"] is not True: + raise RuntimeError(f"Neptune Analytics update upsert reported failure for {vector_id}") + except Exception: + if prior_properties is not None: + try: + restore_query = f""" + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties + """ + self.execute_query( + restore_query, + {"properties": prior_properties, "vector_id": vector_id}, + ) + except Exception: + logger.error( + f"Neptune Analytics: failed to restore prior payload for {vector_id} " + "after a failed vector upsert" + ) + raise diff --git a/tests/vector_stores/test_neptune_analytics.py b/tests/vector_stores/test_neptune_analytics.py index 41cd841df..82f1e3169 100644 --- a/tests/vector_stores/test_neptune_analytics.py +++ b/tests/vector_stores/test_neptune_analytics.py @@ -275,9 +275,116 @@ class TestNeptuneAnalyticsVectorInitValidation: def test_rejects_injection_payload_in_init(self, payload, monkeypatch): from mem0.vector_stores.neptune_analytics import NeptuneAnalyticsVector monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None) - + with pytest.raises(ValueError, match="Invalid collection_name"): NeptuneAnalyticsVector( endpoint="neptune-graph://test", collection_name=payload ) + + +class _FakeNeptuneGraph: + """Minimal stand-in for `NeptuneAnalyticsGraph.query()` so update()'s compensation + path can be exercised without a real Neptune Analytics endpoint.""" + + def __init__(self): + self.nodes = {} + self.fail_next_upsert = False + self.soft_fail_next_upsert = False + self.get_call_count = 0 + + def query(self, query_string, params=None): + params = params or {} + + if "UNWIND $rows" in query_string: + rows = params["rows"] + if "CALL neptune.algo.vectors.upsert" in query_string: + return [{"success": True} for _ in rows] + for row in rows: + self.nodes[row["node_id"]] = dict(row["properties"]) + return [] + + if "CALL neptune.algo.vectors.upsert" in query_string: + if self.fail_next_upsert: + self.fail_next_upsert = False + raise RuntimeError("simulated Neptune upsert failure") + if self.soft_fail_next_upsert: + self.soft_fail_next_upsert = False + return [{"success": False}] + return [{"success": True}] + + if "SET n = $properties" in query_string: + self.nodes[params["vector_id"]] = dict(params["properties"]) + return [] + + if "RETURN n" in query_string and "node_id" in params: + self.get_call_count += 1 + vector_id = params["node_id"] + if vector_id not in self.nodes: + return [] + return [{"n": {"~id": vector_id, "~properties": dict(self.nodes[vector_id])}}] + + if "DETACH DELETE n" in query_string: + self.nodes.pop(params.get("node_id"), None) + return [] + + return [] + + +class TestNeptuneAnalyticsUpdateRollback: + """update() must not leave a payload committed against a stale embedding when the + vector upsert step fails. See the compensation logic in `NeptuneAnalyticsVector.update()`.""" + + def _make_vec(self, monkeypatch): + monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None) + vec = NeptuneAnalyticsVector(endpoint="neptune-graph://test", collection_name="rollback") + vec.graph = _FakeNeptuneGraph() + return vec + + def test_restores_prior_payload_when_upsert_fails(self, monkeypatch): + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.fail_next_upsert = True + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + restored = vec.get("A") + assert restored.payload["data"] == "alpha" + assert restored.payload["user_id"] == "u1" + + def test_does_not_snapshot_prior_state_for_a_vector_only_update(self, monkeypatch): + """Only a combined payload+vector update can desync -- a vector-only update has + nothing to roll back to, so it must skip the extra get() snapshot entirely.""" + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.fail_next_upsert = True + calls_before = vec.graph.get_call_count + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9]) + + assert vec.graph.get_call_count == calls_before + + def test_succeeds_normally_when_upsert_does_not_fail(self, monkeypatch): + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + updated = vec.get("A") + assert updated.payload["data"] == "beta" + + def test_rolls_back_on_soft_upsert_failure(self, monkeypatch): + """A soft {"success": False} row desyncs the payload from the embedding just as much as a + thrown error, so update() must treat it as a failure and roll the payload back too.""" + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.soft_fail_next_upsert = True + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + restored = vec.get("A") + assert restored.payload["data"] == "alpha" + assert restored.payload["user_id"] == "u1"