From 33a0ed75597a9b90c74514d94ee19721a215d8a6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 10 Jul 2026 10:21:05 -0400 Subject: [PATCH] feat(ts-sdk): add AWS Neptune Analytics vector store (#5797) Co-authored-by: kartik-mem0 --- .../vectordbs/dbs/neptune_analytics.mdx | 105 +- docs/components/vectordbs/overview.mdx | 3 +- mem0-ts/package.json | 1 + mem0-ts/pnpm-lock.yaml | 399 +++++- mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/utils/factory.ts | 4 + .../src/oss/src/vector_stores/azure_mysql.ts | 12 +- .../src/vector_stores/neptune_analytics.ts | 1027 ++++++++++++++ mem0-ts/src/oss/tests/factory.unit.test.ts | 32 + mem0-ts/src/oss/tests/optional-peers.test.ts | 70 + .../oss/tests/vector-stores-compat.test.ts | 1264 +++++++++++++++++ mem0-ts/tsup.config.ts | 1 + 12 files changed, 2886 insertions(+), 33 deletions(-) create mode 100644 mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts create mode 100644 mem0-ts/src/oss/tests/optional-peers.test.ts diff --git a/docs/components/vectordbs/dbs/neptune_analytics.mdx b/docs/components/vectordbs/dbs/neptune_analytics.mdx index 12d601b99..a69adf9ba 100644 --- a/docs/components/vectordbs/dbs/neptune_analytics.mdx +++ b/docs/components/vectordbs/dbs/neptune_analytics.mdx @@ -2,26 +2,37 @@ title: "Neptune Analytics" description: "Use AWS Neptune Analytics as a vector store in Mem0, combining graph analytics with vector search capabilities." --- -# Neptune Analytics Vector Store -[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html/) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +### Installation -## Installation +The Neptune Analytics provider needs the AWS Neptune Graph client. Install it alongside `mem0ai`: -```bash + +```bash Python pip install mem0ai[vector-stores] ``` -## Usage +```bash TypeScript +npm install @aws-sdk/client-neptune-graph +``` + + +### Usage + +Configure AWS credentials in your environment (environment variables, shared config file, an IAM role, or an instance profile). Both SDKs pick them up automatically through the standard AWS credential chain. + + +```python Python +from mem0 import Memory -```python config = { "vector_store": { "provider": "neptune", "config": { "collection_name": "mem0", - "endpoint": f"neptune-graph://my-graph-identifier", + "endpoint": "neptune-graph://g-abc123xyz0", }, }, } @@ -29,18 +40,90 @@ config = { m = Memory.from_config(config) messages = [ {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, - {"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."}, + {"role": "assistant", "content": "How about a thriller movie? 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."} ] m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` -## Parameters +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; -Let's see the available parameters for the `neptune` config: +const config = { + vectorStore: { + provider: 'neptune', + config: { + collectionName: 'mem0', + graphIdentifier: 'g-abc123xyz0', + // Any other key here (region, credentials, maxAttempts, ...) is + // forwarded to the underlying NeptuneGraphClient constructor. + region: 'us-east-1', + }, + }, +}; +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 a thriller movie? 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 + + + | Parameter | Description | Default Value | | --- | --- | --- | | `collection_name` | The name of the collection to store the vectors | `mem0` | -| `endpoint` | Connection URL for the Neptune Analytics service | `neptune-graph://my-graph-identifier` | +| `endpoint` | Connection URL for the Neptune Analytics service, must be `neptune-graph://` | Required | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `collectionName` | The name of the collection to store the vectors | `memories` | +| `graphIdentifier` | Graph ID, e.g. `g-abc123xyz0`. Takes priority over `endpoint`. | Required, unless `endpoint` supplies it | +| `endpoint` | Either `neptune-graph://` (or a bare graph ID) to supply the graph ID, or an `https://` service endpoint to override the AWS endpoint. An `https://` value must be paired with `graphIdentifier`. | `undefined` | +| `dimension` | Embedding vector dimension | Auto-detected from the embedder when omitted | +| `client` | A pre-built `NeptuneGraphClient` to use instead of constructing one | `undefined` | +| any other key | Forwarded as-is to the [`NeptuneGraphClient`](https://www.npmjs.com/package/@aws-sdk/client-neptune-graph) constructor, e.g. `region`, `credentials`, `maxAttempts` | N/A | + + + +Both SDKs store vectors on graph nodes labeled `MEM0_VECTOR_`. Point them at the same +graph with the same `collection_name` — the defaults differ, `mem0` in Python and `memories` in +TypeScript — and `get()`, `list()`, and `delete()` interoperate across SDKs. + + +`search()` is not currently cross-SDK compatible. The TypeScript provider filters on Neptune's reserved +`~label` metafield, while the Python provider filters on a synthetic `label` property that only Python's +own `insert()` writes. Python's `search()` therefore cannot see nodes written by the TypeScript provider. + + +### IAM Permissions + +Your AWS identity (user or role) needs a policy that allows the [`ExecuteQuery`](https://docs.aws.amazon.com/neptune-analytics/latest/apiref/API_ExecuteQuery.html) actions used for reads, writes, and deletes: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "neptune-graph:ReadDataViaQuery", + "neptune-graph:WriteDataViaQuery", + "neptune-graph:DeleteDataViaQuery" + ], + "Resource": "*" + } + ] +} +``` + +For production, scope the resource ARN down to your specific graph. diff --git a/docs/components/vectordbs/overview.mdx b/docs/components/vectordbs/overview.mdx index b71568453..18d2209fe 100644 --- a/docs/components/vectordbs/overview.mdx +++ b/docs/components/vectordbs/overview.mdx @@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize See the list of supported vector databases below. - The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, and an in-memory store. + The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store. @@ -32,6 +32,7 @@ See the list of supported vector databases below. + diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 953fcd32c..497a1e1f9 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -109,6 +109,7 @@ }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", + "@aws-sdk/client-neptune-graph": "3.966.0", "@aws-sdk/client-s3vectors": "3.967.0", "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index f3461e62f..54b3d643a 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@aws-sdk/client-bedrock-runtime': specifier: 3.967.0 version: 3.967.0 + '@aws-sdk/client-neptune-graph': + specifier: 3.966.0 + version: 3.966.0 '@aws-sdk/client-s3vectors': specifier: 3.967.0 version: 3.967.0 @@ -113,7 +116,7 @@ importers: version: 4.5.0 cohere-ai: specifier: ^7.17.0 || ^8.0.0 - version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2) + version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3) compromise: specifier: ^14.0.0 version: 14.15.1 @@ -254,6 +257,10 @@ packages: resolution: {integrity: sha512-pFID1Lb/54u413HTpnqQYLJjX+voEKLZyqlpdVHDbxcw8MfalmmSg5PR/uJWGO3kku0LkB+H/Ca5ftL11PTL9w==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-neptune-graph@3.966.0': + resolution: {integrity: sha512-O10O8/hKrZiSGxAQUG0T1UcdIRfE9szG7toipoUoYsbI6BOlov8U6zWz43ao+TfUCeI0luNcjevGhiqt04lB/w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-s3@3.1083.0': resolution: {integrity: sha512-3YOicHy6qjexsy4rHk5jPvtLgg6Ho1u+ycOWtJcVTAIMlMq32MRtnhllAyFSezhAXeBnnLLtKtAGXim7vV8oog==} engines: {node: '>=20.0.0'} @@ -262,10 +269,18 @@ packages: resolution: {integrity: sha512-DDmq8Jm/4IwliVEYVUbTub0RhcYJXVexRklhsIOdIWM42zf4ZMWPeeNvHJBSI+DMNbpqRN57TLCfH5tgiPdyVQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.966.0': + resolution: {integrity: sha512-hQZDQgqRJclALDo9wK+bb5O+VpO8JcjImp52w9KPSz9XveNRgE9AYfklRJd8qT2Bwhxe6IbnqYEino2wqUMA1w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.967.0': resolution: {integrity: sha512-7RgUwHcRMJtWme6kCHGUVT+Rn9GmNH+FHm34N9UgMXzUqQlzFMweE7T5E9O8nv3wIp7xFNB20ADaCw9Xdnox1Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.966.0': + resolution: {integrity: sha512-QaRVBHD1prdrFXIeFAY/1w4b4S0EFyo/ytzU+rCklEjMRT7DKGXGoHXTWLGz+HD7ovlS5u+9cf8a/LeSOEMzww==} + engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.967.0': resolution: {integrity: sha512-sJmuP7GrVmlbO6DpXkuf9Mbn6jGNNvy6PLawvaxVF150c8bpNk3w39rerRls6q1dot1dBFV2D29hBXMY1agNMg==} engines: {node: '>=18.0.0'} @@ -274,6 +289,10 @@ packages: resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.966.0': + resolution: {integrity: sha512-sxVKc9PY0SH7jgN/8WxhbKQ7MWDIgaJv1AoAKJkhJ+GM5r09G5Vb2Vl8ALYpsy+r8b+iYpq5dGJj8k2VqxoQMg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.967.0': resolution: {integrity: sha512-+XWw0+f/txeMbEVRtTFZhgSw1ymH1ffaVKkdMBSnw48rfSohJElKmitCqdihagRTZpzh7m8qI6tIQ5t3OUqugw==} engines: {node: '>=18.0.0'} @@ -282,6 +301,10 @@ packages: resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.966.0': + resolution: {integrity: sha512-VTJDP1jOibVtc5pn5TNE12rhqOO/n10IjkoJi8fFp9BMfmh3iqo70Ppvphz/Pe/R9LcK5Z3h0Z4EB9IXDR6kag==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.967.0': resolution: {integrity: sha512-0/GIAEv5pY5htg6IBMuYccBgzz3oS2DqHjHi396ziTrwlhbrCNX96AbNhQhzAx3LBZUk13sPfeapjyQ7G57Ekg==} engines: {node: '>=18.0.0'} @@ -290,6 +313,10 @@ packages: resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.966.0': + resolution: {integrity: sha512-4oQKkYMCUx0mffKuH8LQag1M4Fo5daKVmsLAnjrIqKh91xmCrcWlAFNMgeEYvI1Yy125XeNSaFMfir6oNc2ODA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.967.0': resolution: {integrity: sha512-U8dMpaM6Qf6+2Qvp1uG6OcWv1RlrZW7tQkpmzEVWH8HZTGrVHIXXju64NMtIOr7yOnNwd0CKcytuD1QG+phCwQ==} engines: {node: '>=18.0.0'} @@ -298,6 +325,10 @@ packages: resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.966.0': + resolution: {integrity: sha512-wD1KlqLyh23Xfns/ZAPxebwXixoJJCuDbeJHFrLDpP4D4h3vA2S8nSFgBSFR15q9FhgRfHleClycf6g5K4Ww6w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-login@3.967.0': resolution: {integrity: sha512-kbvZsZL6CBlfnb71zuJdJmBUFZN5utNrcziZr/DZ2olEOkA9vlmizE8i9BUIbmS7ptjgvRnmcY1A966yfhiblw==} engines: {node: '>=18.0.0'} @@ -306,6 +337,10 @@ packages: resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.966.0': + resolution: {integrity: sha512-7QCOERGddMw7QbjE+LSAFgwOBpPv4px2ty0GCK7ZiPJGsni2EYmM4TtYnQb9u1WNHmHqIPWMbZR0pKDbyRyHlQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.967.0': resolution: {integrity: sha512-WuNbHs9rfKKSVok4+OBrZf0AHfzDgFYYMxN2G/q6ZfUmY4QmiPyxV5HkNFh1rqDxS9VV6kAZPo0EBmry10idSg==} engines: {node: '>=18.0.0'} @@ -314,6 +349,10 @@ packages: resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.966.0': + resolution: {integrity: sha512-q5kCo+xHXisNbbPAh/DiCd+LZX4wdby77t7GLk0b2U0/mrel4lgy6o79CApe+0emakpOS1nPZS7voXA7vGPz4w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.967.0': resolution: {integrity: sha512-sNCY5JDV0whsfsZ6c2+6eUwH33H7UhKbqvCPbEYlIIa8wkGjCtCyFI3zZIJHVcMKJJ3117vSUFHEkNA7g+8rtw==} engines: {node: '>=18.0.0'} @@ -322,6 +361,10 @@ packages: resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.966.0': + resolution: {integrity: sha512-Rv5aEfbpqsQZzxpX2x+FbSyVFOE3Dngome+exNA8jGzc00rrMZEUnm3J3yAsLp/I2l7wnTfI0r2zMe+T9/nZAQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.967.0': resolution: {integrity: sha512-0K6kITKNytFjk1UYabYUsTThgU6TQkyW6Wmt8S5zd1A/up7NSQGpp58Rpg9GIf4amQDQwb+p9FGG7emmV8FEeA==} engines: {node: '>=18.0.0'} @@ -330,6 +373,10 @@ packages: resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.966.0': + resolution: {integrity: sha512-Yv1lc9iic9xg3ywMmIAeXN1YwuvfcClLVdiF2y71LqUgIOupW8B8my84XJr6pmOQuKzZa++c2znNhC9lGsbKyw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.967.0': resolution: {integrity: sha512-Vkr7S2ec7q/v8i/MzkHcBEdqqfWz3lyb8FDjb+NjslEwdxC3f6XwADRZzWwV1pChfx6SbsvJXKfkcF/pKAelhA==} engines: {node: '>=18.0.0'} @@ -362,6 +409,10 @@ packages: resolution: {integrity: sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.966.0': + resolution: {integrity: sha512-MvGoy0vhMluVpSB5GaGJbYLqwbZfZjwEZhneDHdPhgCgQqmCtugnYIIjpUw7kKqWGsmaMQmNEgSFf1zYYmwOyg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.967.0': resolution: {integrity: sha512-2qzJzZj5u+cZiG7kz3XJPaTH4ssUY/aet1kwJsUTFKrWeHUf7mZZkDFfkXP5cOffgiOyR5ZkrmJoLKAde9hshg==} engines: {node: '>=18.0.0'} @@ -371,6 +422,10 @@ packages: engines: {node: '>= 14.0.0'} deprecated: Please update your @aws-sdk client to a more recent version, such as https://github.com/aws/aws-sdk-js-v3/releases/tag/v3.982.0, if using browser-based WebSocket bidirectional streaming. + '@aws-sdk/nested-clients@3.966.0': + resolution: {integrity: sha512-FRzAWwLNoKiaEWbYhnpnfartIdOgiaBLnPcd3uG1Io+vvxQUeRPhQIy4EfKnT3AuA+g7gzSCjMG2JKoJOplDtQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.967.0': resolution: {integrity: sha512-PYa7V8w0gaNux6Sz/Z7zrHmPloEE+EKpRxQIOG/D0askTr5Yd4oO2KGgcInf65uHK3f0Z9U4CTUGHZvQvABypA==} engines: {node: '>=18.0.0'} @@ -391,6 +446,10 @@ packages: resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.966.0': + resolution: {integrity: sha512-8k5cBTicTGYJHhKaweO4gL4fud1KDnLS5fByT6/Xbiu59AxYM4E/h3ds+3jxDMnniCE3gIWpEnyfM9khtmw2lA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.967.0': resolution: {integrity: sha512-Qnd/nJ0CgeUa7zQczgmdQm0vYUF7pD1G0C+dR1T7huHQHRIsgCWIsCV9wNKzOFluqtcr6YAeuTwvY0+l8XWxnA==} engines: {node: '>=18.0.0'} @@ -422,6 +481,15 @@ packages: '@aws-sdk/util-user-agent-browser@3.965.0': resolution: {integrity: sha512-Xiza/zMntQGpkd2dETQeAK8So1pg5+STTzpcdGWxj5q0jGO5ayjqT/q1Q7BrsX5KIr6PvRkl9/V7lLCv04wGjQ==} + '@aws-sdk/util-user-agent-node@3.966.0': + resolution: {integrity: sha512-vPPe8V0GLj+jVS5EqFz2NUBgWH35favqxliUOvhp8xBdNRkEjiZm5TqitVtFlxS4RrLY3HOndrWbrP5ejbwl1Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/util-user-agent-node@3.967.0': resolution: {integrity: sha512-yUz6pCGxyG4+QaDg0dkdIBphjQp8A9rrbZa/+U3RJgRrW47hy64clFQUROzj5Poy1Ur8ICVXEUpBsSqRuYEU2g==} engines: {node: '>=18.0.0'} @@ -1658,6 +1726,10 @@ packages: resolution: {integrity: sha512-N5CpfaL+/LPQU9PFdOT55ayUo5T0QypG4Almzd1/efJvoDypuT1shkgJk1+hhg/02scYluW6Q2JGnSHIPwCEGQ==} engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.5.7': + resolution: {integrity: sha512-9NFs7MyxFBEQenFHrveJK/rirLSnYAgCMfAqKvvA+UlyzJCKFc51TYKbMUv5rGsM4bQ419Fw6KKgHc53QyJ30w==} + engines: {node: '>=18.0.0'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -4436,7 +4508,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/util-locate-window': 3.965.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4444,7 +4516,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -4517,6 +4589,52 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-neptune-graph@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-node': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-stream': 4.7.2 + '@smithy/util-utf8': 4.4.2 + '@smithy/util-waiter': 4.5.7 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.1083.0': dependencies: '@aws-sdk/checksums': 3.1000.16 @@ -4575,6 +4693,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4618,6 +4779,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/core@3.966.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@aws-sdk/xml-builder': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-base64': 4.5.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + '@aws-sdk/core@3.967.0': dependencies: '@aws-sdk/types': 3.965.0 @@ -4645,6 +4822,14 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4661,6 +4846,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-stream': 4.7.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4684,6 +4882,25 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-login': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4719,6 +4936,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4741,6 +4971,23 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.966.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-ini': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.967.0': dependencies: '@aws-sdk/credential-provider-env': 3.967.0 @@ -4772,6 +5019,15 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4789,6 +5045,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.966.0': + dependencies: + '@aws-sdk/client-sso': 3.966.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/token-providers': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.967.0': dependencies: '@aws-sdk/client-sso': 3.967.0 @@ -4812,6 +5081,18 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4851,13 +5132,13 @@ snapshots: dependencies: '@aws-sdk/types': 3.965.0 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.965.0': @@ -4865,7 +5146,7 @@ snapshots: '@aws-sdk/types': 3.965.0 '@aws/lambda-invoke-store': 0.2.4 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-sdk-s3@3.972.62': @@ -4877,6 +5158,16 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4900,6 +5191,49 @@ snapshots: '@smithy/util-hex-encoding': 4.4.6 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/nested-clients@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4959,7 +5293,7 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/config-resolver': 4.6.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.39': @@ -4978,6 +5312,18 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/token-providers@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4992,7 +5338,7 @@ snapshots: '@aws-sdk/types@3.965.0': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/types@3.973.13': @@ -5008,7 +5354,7 @@ snapshots: '@aws-sdk/util-endpoints@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 '@smithy/url-parser': 4.4.2 '@smithy/util-endpoints': 3.6.2 tslib: 2.8.1 @@ -5027,10 +5373,18 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.966.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/node-config-provider': 4.5.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.967.0': dependencies: '@aws-sdk/middleware-user-agent': 3.967.0 @@ -6171,12 +6525,12 @@ snapshots: '@smithy/hash-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/invalid-dependency@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -6190,7 +6544,7 @@ snapshots: '@smithy/middleware-endpoint@4.6.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-retry@4.7.2': @@ -6200,7 +6554,7 @@ snapshots: '@smithy/middleware-serde@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-stack@4.4.2': @@ -6294,7 +6648,7 @@ snapshots: '@smithy/util-base64@4.5.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-body-length-browser@4.4.2': @@ -6304,7 +6658,7 @@ snapshots: '@smithy/util-body-length-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -6334,7 +6688,7 @@ snapshots: '@smithy/util-middleware@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-retry@4.5.2': @@ -6354,7 +6708,12 @@ snapshots: '@smithy/util-utf8@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.5.7': + dependencies: + '@smithy/core': 3.29.2 tslib: 2.8.1 '@so-ric/colorspace@1.1.6': @@ -6894,7 +7253,7 @@ snapshots: co@4.6.0: {} - cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2): + cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3): dependencies: convict: 6.2.5 form-data: 4.0.6 @@ -6904,7 +7263,7 @@ snapshots: optionalDependencies: '@aws-crypto/sha256-js': 5.2.0 '@smithy/protocol-http': 5.5.2 - '@smithy/signature-v4': 5.6.2 + '@smithy/signature-v4': 5.6.3 collect-v8-coverage@1.0.3: {} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 677a73cf7..84c6316e6 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/neptune_analytics"; export * from "./vector_stores/elasticsearch"; export * from "./vector_stores/upstash_vector"; export * from "./vector_stores/azure_mysql"; diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 4a4930938..2bed7f484 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 { NeptuneAnalyticsVectorStore } from "../vector_stores/neptune_analytics"; import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; import { OpenSearchDB } from "../vector_stores/opensearch"; @@ -172,6 +173,9 @@ export class VectorStoreFactory { return new VertexAIVectorSearch(config as any); case "pgvector": return new PGVector(config as any); + case "neptune": + case "neptune-analytics": + return new NeptuneAnalyticsVectorStore(config as any); case "elasticsearch": return new ElasticsearchDB(config as any); case "opensearch": diff --git a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts index 0b5bdf210..b76e6437a 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts @@ -1,4 +1,3 @@ -import { createPool } from "mysql2/promise"; import type { Pool, RowDataPacket } from "mysql2/promise"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -93,6 +92,17 @@ export class AzureMySQLDB implements VectorStore { ...(this.config.sslCa ? { ca: this.config.sslCa } : {}), }; + // Loaded dynamically: mysql2 is an optional peer dependency, so a static value import + // would break `import { Memory } from "mem0ai/oss"` for everyone else. + let createPool: typeof import("mysql2/promise").createPool; + try { + ({ createPool } = await import("mysql2/promise")); + } catch { + throw new Error( + "The Azure MySQL vector store requires the 'mysql2' package. Install it with: npm install mysql2", + ); + } + this.pool = createPool({ host: this.config.host, port: this.config.port ?? 3306, diff --git a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts new file mode 100644 index 000000000..9349af63f --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts @@ -0,0 +1,1027 @@ +import { + ExecuteQueryCommand, + NeptuneGraphClient, +} from "@aws-sdk/client-neptune-graph"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +interface NeptuneAnalyticsConfig extends VectorStoreConfig { + graphIdentifier?: string; + endpoint?: string; + collectionName: string; + dimension?: number; + client?: NeptuneGraphClientLike; +} + +interface NeptuneGraphClientLike { + send(command: ExecuteQueryCommand): Promise; +} + +interface NeptuneExecuteQueryOutput { + payload?: { + transformToString(encoding?: string): Promise; + }; +} + +type NeptuneQueryRecord = Record; +type NeptuneVertexFilter = Record; + +interface WhereClauseResult { + clause: string; + parameters: Record; + nextIndex: number; +} + +export class NeptuneAnalyticsVectorStore implements VectorStore { + private readonly client: NeptuneGraphClientLike; + private readonly graphIdentifier: string; + private readonly collectionName: string; + private readonly collectionLabel: string; + private readonly collectionLabelExpr: string; + private readonly userLabel: string; + private readonly userLabelExpr: string; + private readonly userNodeId: string; + private readonly dimension: number; + private _initPromise?: Promise; + private cachedUserId?: string; + + constructor(config: NeptuneAnalyticsConfig) { + this.graphIdentifier = this.resolveGraphIdentifier(config); + this.collectionName = config.collectionName || "memories"; + this.collectionLabel = `MEM0_VECTOR_${this.collectionName}`; + this.collectionLabelExpr = this.escapeLabel(this.collectionLabel); + this.userLabel = "MEM0_VECTOR_memory_migrations"; + this.userLabelExpr = this.escapeLabel(this.userLabel); + this.userNodeId = "mem0-user"; + this.dimension = config.dimension || 1536; + this.client = + config.client || new NeptuneGraphClient(this.buildClientConfig(config)); + + void this.initialize().catch(console.error); + } + + initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize(); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + return; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + this.assertBatchDimensions(vectors, "Insert"); + const existingIds = await this.findExistingIds(ids); + + const rows = vectors.map((vector, index) => ({ + node_id: ids[index], + properties: this.buildStoredPayload(payloads[index] || {}), + embedding: vector, + })); + + const propertiesQuery = ` + UNWIND $rows AS row + MERGE (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + ON CREATE SET n = row.properties + ON MATCH SET n += row.properties + `; + + const vectorQuery = ` + UNWIND $rows AS row + MATCH (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + WITH n, row.embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `; + + try { + await this.executeQuery(propertiesQuery, { rows }); + const results = await this.executeQuery(vectorQuery, { rows }); + this.assertSuccessfulResults(results, "Insert"); + } catch (error) { + await this.cleanupFailedInsert(ids.filter((id) => !existingIds.has(id))); + throw error; + } + } + + async keywordSearch(): Promise { + return null; + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + this.assertVectorDimension(query, "Query"); + + const vertexFilter = this.buildVertexFilter(filters); + const results = await this.executeQuery( + ` + CALL neptune.algo.vectors.topK.byEmbedding( + ${this.serializeAlgorithmInput({ + topK, + embedding: query, + vertexFilter, + })} + ) + YIELD node, score + RETURN node, score + `, + ); + + return results.map((record) => this.normalizeSearchResult(record)); + } + + async get(vectorId: string): Promise { + const results = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + RETURN n + LIMIT 1 + `, + { + vectorId, + }, + ); + + if (results.length === 0) { + return null; + } + + return this.normalizeNodeResult(results[0]); + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + if (vector.length > 0) { + this.assertVectorDimension(vector, "Vector"); + } + + const hasPayload = !!payload && Object.keys(payload).length > 0; + const hasVector = vector.length > 0; + + if (hasPayload) { + const properties = this.buildStoredPayload(payload); + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties, + }, + ); + } + + 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"); + } + } + + async delete(vectorId: string): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + DETACH DELETE n + `, + { + vectorId, + }, + ); + } + + async deleteCol(): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + DETACH DELETE n + `, + ); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + const { clause, parameters } = this.buildWhereClause(filters); + const whereClause = clause ? `WHERE ${clause}` : ""; + const [results, countResults] = await Promise.all([ + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN n + LIMIT $limit + `, + { + ...parameters, + limit: topK, + }, + ), + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN count(n) AS count + `, + parameters, + ), + ]); + + const items = results.map((record) => this.normalizeNodeResult(record)); + const count = Number(countResults[0]?.count); + return [items, Number.isFinite(count) ? count : items.length]; + } + + async getUserId(): Promise { + if (this.cachedUserId) { + return this.cachedUserId; + } + + const results = await this.executeQuery( + ` + MATCH (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + RETURN n + LIMIT 1 + `, + { + userNodeId: this.userNodeId, + }, + ); + + const existing = results[0]; + const userId = existing ? this.extractUserId(existing) : undefined; + if (userId) { + this.cachedUserId = userId; + return userId; + } + + const randomUserId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(randomUserId); + return randomUserId; + } + + async setUserId(userId: string): Promise { + await this.executeQuery( + ` + MERGE (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + SET n.user_id = $userId + RETURN n + `, + { + userNodeId: this.userNodeId, + userId, + }, + ); + this.cachedUserId = userId; + } + + private async findExistingIds(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) { + return new Set(); + } + + const results = await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + RETURN nodeId + `, + { + nodeIds, + }, + ); + + return new Set( + results + .map((record) => record.nodeId) + .filter((nodeId): nodeId is string => typeof nodeId === "string"), + ); + } + + private async cleanupFailedInsert(nodeIds: string[]): Promise { + if (nodeIds.length === 0) { + return; + } + + try { + await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + DETACH DELETE n + `, + { + nodeIds, + }, + ); + } catch (error) { + console.error( + "Neptune Analytics: failed to clean up node(s) after a failed insert", + error, + ); + } + } + + private resolveGraphIdentifier(config: NeptuneAnalyticsConfig): string { + if (config.graphIdentifier) { + return config.graphIdentifier; + } + + const rawIdentifier = config.endpoint; + + if (!rawIdentifier) { + throw new Error( + "Neptune Analytics vector store requires graphIdentifier or endpoint.", + ); + } + + if (/^https?:\/\//i.test(rawIdentifier)) { + throw new Error( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + } + + if (rawIdentifier.startsWith("neptune-graph://")) { + return rawIdentifier.slice("neptune-graph://".length); + } + + return rawIdentifier; + } + + private buildClientConfig(config: NeptuneAnalyticsConfig): { + [key: string]: any; + endpoint?: string; + } { + const { + client: _client, + collectionName: _collectionName, + dimension: _dimension, + graphIdentifier: _graphIdentifier, + endpoint, + ...clientConfig + } = config; + + if (endpoint && /^https?:\/\//i.test(endpoint)) { + return { + ...clientConfig, + endpoint, + }; + } + + return clientConfig; + } + + private escapeLabel(label: string): string { + return `\`${label.replace(/`/g, "``")}\``; + } + + private buildStoredPayload( + payload: Record, + ): Record { + return { + ...payload, + updatedAt: new Date().toISOString(), + }; + } + + private buildVertexFilter(filters?: SearchFilters): NeptuneVertexFilter { + const conditions: NeptuneVertexFilter[] = [ + { + equals: { + property: "~label", + value: this.collectionLabel, + }, + }, + ]; + + const metadataFilter = this.buildMetadataVertexFilter(filters); + if (metadataFilter) { + conditions.push(metadataFilter); + } + + return this.combineVertexFilters("andAll", conditions)!; + } + + private buildMetadataVertexFilter( + filters?: SearchFilters, + ): NeptuneVertexFilter | undefined { + const operations: NeptuneVertexFilter[] = []; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry); + const joiner = key === "$and" ? "andAll" : "orAll"; + const combined = this.combineVertexFilters(joiner, nested); + if (combined) { + operations.push(combined); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry) + .map((entry) => this.negateVertexFilter(entry)); + const combined = this.combineVertexFilters("andAll", nested); + if (combined) { + operations.push(combined); + } + continue; + } + + operations.push(this.buildFieldVertexFilter(key, value)); + } + + return this.combineVertexFilters("andAll", operations); + } + + private combineVertexFilters( + joiner: "andAll" | "orAll", + operations: NeptuneVertexFilter[], + ): NeptuneVertexFilter | undefined { + if (operations.length === 0) { + return undefined; + } + + if (operations.length === 1) { + return operations[0]; + } + + return { + [joiner]: operations, + }; + } + + private buildFieldVertexFilter(key: string, value: any): NeptuneVertexFilter { + if (value === "*") { + throw new Error( + "Neptune Analytics vector search does not support property-existence filters.", + ); + } + + if (Array.isArray(value)) { + return { + in: { + property: key, + value, + }, + }; + } + + if (typeof value !== "object" || value === null) { + return { + equals: { + property: key, + value, + }, + }; + } + + const operations = Object.entries(value).map(([operator, operand]) => + this.buildSingleVertexFilter(key, operator, operand), + ); + + return this.combineVertexFilters("andAll", operations)!; + } + + private buildSingleVertexFilter( + key: string, + operator: string, + operand: any, + ): NeptuneVertexFilter { + switch (operator) { + case "eq": + return { + equals: { + property: key, + value: operand, + }, + }; + case "ne": + return { + notEquals: { + property: key, + value: operand, + }, + }; + case "gt": + return { + greaterThan: { + property: key, + value: operand, + }, + }; + case "gte": + return { + greaterThanOrEquals: { + property: key, + value: operand, + }, + }; + case "lt": + return { + lessThan: { + property: key, + value: operand, + }, + }; + case "lte": + return { + lessThanOrEquals: { + property: key, + value: operand, + }, + }; + case "in": + return { + in: { + property: key, + value: operand, + }, + }; + case "nin": + return { + notIn: { + property: key, + value: operand, + }, + }; + case "contains": + return { + stringContains: { + property: key, + value: operand, + }, + }; + case "startsWith": + return { + startsWith: { + property: key, + value: operand, + }, + }; + case "icontains": + throw new Error( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + private negateVertexFilter(filter: NeptuneVertexFilter): NeptuneVertexFilter { + if (Array.isArray(filter.andAll)) { + return this.combineVertexFilters( + "orAll", + filter.andAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (Array.isArray(filter.orAll)) { + return this.combineVertexFilters( + "andAll", + filter.orAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (filter.equals) { + return { + notEquals: filter.equals, + }; + } + + if (filter.notEquals) { + return { + equals: filter.notEquals, + }; + } + + if (filter.greaterThan) { + return { + lessThanOrEquals: filter.greaterThan, + }; + } + + if (filter.greaterThanOrEquals) { + return { + lessThan: filter.greaterThanOrEquals, + }; + } + + if (filter.lessThan) { + return { + greaterThanOrEquals: filter.lessThan, + }; + } + + if (filter.lessThanOrEquals) { + return { + greaterThan: filter.lessThanOrEquals, + }; + } + + if (filter.in) { + return { + notIn: filter.in, + }; + } + + if (filter.notIn) { + return { + in: filter.notIn, + }; + } + + throw new Error( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + } + + private buildWhereClause( + filters?: SearchFilters, + startIndex: number = 1, + ): WhereClauseResult { + const clauses: string[] = []; + const parameters: Record = {}; + let nextIndex = startIndex; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + const joiner = key === "$and" ? " AND " : " OR "; + clauses.push(`(${nestedClauses.join(joiner)})`); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + clauses.push(`NOT (${nestedClauses.join(" OR ")})`); + } + continue; + } + + const fieldResult = this.buildFieldWhereClauses(key, value, nextIndex); + nextIndex = fieldResult.nextIndex; + Object.assign(parameters, fieldResult.parameters); + clauses.push(...fieldResult.clauses); + } + + return { + clause: clauses.join(" AND "), + parameters, + nextIndex, + }; + } + + private buildFieldWhereClauses( + key: string, + value: any, + startIndex: number, + ): { + clauses: string[]; + parameters: Record; + nextIndex: number; + } { + const field = `n.${this.escapeProperty(key)}`; + const parameters: Record = {}; + const clauses: string[] = []; + let nextIndex = startIndex; + + const addParameter = (prefix: string, rawValue: any) => { + const parameterName = `${prefix}_${key.replace(/[^\w]/g, "_")}_${nextIndex}`; + parameters[parameterName] = rawValue; + nextIndex += 1; + return parameterName; + }; + + if (value === "*") { + return { + clauses: [`${field} IS NOT NULL`], + parameters, + nextIndex, + }; + } + + if (Array.isArray(value)) { + const parameterName = addParameter("filter_in", value); + return { + clauses: [`${field} IN $${parameterName}`], + parameters, + nextIndex, + }; + } + + if (typeof value !== "object" || value === null) { + const parameterName = addParameter("filter", value); + return { + clauses: [`${field} = $${parameterName}`], + parameters, + nextIndex, + }; + } + + for (const [operator, operand] of Object.entries(value)) { + const parameterName = addParameter(`filter_${operator}`, operand); + switch (operator) { + case "eq": + clauses.push(`${field} = $${parameterName}`); + break; + case "ne": + clauses.push(`${field} <> $${parameterName}`); + break; + case "gt": + clauses.push(`${field} > $${parameterName}`); + break; + case "gte": + clauses.push(`${field} >= $${parameterName}`); + break; + case "lt": + clauses.push(`${field} < $${parameterName}`); + break; + case "lte": + clauses.push(`${field} <= $${parameterName}`); + break; + case "in": + clauses.push(`${field} IN $${parameterName}`); + break; + case "nin": + clauses.push(`NOT ${field} IN $${parameterName}`); + break; + case "contains": + clauses.push(`toString(${field}) CONTAINS $${parameterName}`); + break; + case "icontains": + throw new Error( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + case "startsWith": + clauses.push(`toString(${field}) STARTS WITH $${parameterName}`); + break; + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + return { + clauses, + parameters, + nextIndex, + }; + } + + private escapeProperty(key: string): string { + return `\`${key.replace(/`/g, "``")}\``; + } + + private serializeAlgorithmInput(value: any): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => this.serializeAlgorithmInput(entry)).join(", ")}]`; + } + + if (value === null) { + return "null"; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (typeof value === "object") { + return `{ ${Object.entries(value) + .map( + ([key, entry]) => + `${this.serializeAlgorithmKey(key)}: ${this.serializeAlgorithmInput(entry)}`, + ) + .join(", ")} }`; + } + + throw new Error( + `Unsupported Neptune Analytics algorithm value type: ${typeof value}`, + ); + } + + private serializeAlgorithmKey(key: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key; + } + + return JSON.stringify(key); + } + + private assertVectorDimension(vector: number[], context: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${context} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + } + + private assertBatchDimensions(vectors: number[][], context: string): void { + for (const vector of vectors) { + this.assertVectorDimension(vector, context); + } + } + + private normalizeNodeResult(record: NeptuneQueryRecord): VectorStoreResult { + const node = this.extractNode(record); + const payload = this.normalizePayload(this.extractPayload(node)); + + return { + id: this.extractId(node, record), + payload, + }; + } + + private normalizeSearchResult(record: NeptuneQueryRecord): VectorStoreResult { + const base = this.normalizeNodeResult(record); + const score = this.normalizeScore(record.score); + + return { + ...base, + score, + }; + } + + private extractNode(record: NeptuneQueryRecord): Record { + return ( + record.n || record.node || record.m || record.item || record.v || record + ); + } + + private extractPayload(node: Record): Record { + return node["~properties"] || node.properties || node.payload || {}; + } + + private extractId( + node: Record, + record: NeptuneQueryRecord, + ): string { + const rawId = node["~id"] || node.id || record.id; + return String(rawId); + } + + private extractUserId(record: NeptuneQueryRecord): string | undefined { + const node = this.extractNode(record); + const payload = this.extractPayload(node); + const userId = payload.user_id || payload.userId; + return userId ? String(userId) : undefined; + } + + private normalizePayload(payload: Record): Record { + return { ...payload }; + } + + private normalizeScore(score: unknown): number | undefined { + if (score === undefined || score === null) { + return undefined; + } + + const numericScore = Number(score); + if (!Number.isFinite(numericScore)) { + return undefined; + } + + // Neptune returns squared Euclidean distance, while Memory search expects higher-is-better scores. + return 1 / (1 + Math.max(0, numericScore)); + } + + private assertSuccessfulResults( + results: NeptuneQueryRecord[], + context: string, + ): void { + for (const record of results) { + if ("success" in record && record.success !== true) { + throw new Error(`${context} failed in Neptune Analytics`); + } + } + } + + private async executeQuery( + queryString: string, + parameters: Record = {}, + ): Promise { + const response = await this.client.send( + new ExecuteQueryCommand({ + graphIdentifier: this.graphIdentifier, + language: "OPEN_CYPHER", + queryString, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined, + }), + ); + + const rawPayload = response.payload + ? await response.payload.transformToString("utf-8") + : ""; + + if (!rawPayload.trim()) { + return []; + } + + const parsed = JSON.parse(rawPayload) as + | NeptuneQueryRecord[] + | { results?: NeptuneQueryRecord[]; result?: NeptuneQueryRecord[] }; + + if (Array.isArray(parsed)) { + return parsed; + } + + if (Array.isArray(parsed.results)) { + return parsed.results; + } + + if (Array.isArray(parsed.result)) { + return parsed.result; + } + + return []; + } +} diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index fa7e0d322..0a3994303 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -183,6 +183,12 @@ jest.mock("../src/vector_stores/pgvector", () => ({ .fn() .mockImplementation((config) => ({ type: "pgvector", config })), })); +jest.mock("../src/vector_stores/neptune_analytics", () => ({ + NeptuneAnalyticsVectorStore: jest.fn().mockImplementation((config) => ({ + type: "neptune-analytics", + config, + })), +})); jest.mock("../src/vector_stores/upstash_vector", () => ({ UpstashVector: jest .fn() @@ -340,6 +346,8 @@ describe("VectorStoreFactory", () => { ["vectorize"], ["azure-ai-search"], ["pgvector"], + ["neptune"], + ["neptune-analytics"], ["upstash_vector"], ["azure_mysql"], ["cassandra"], @@ -351,6 +359,30 @@ describe("VectorStoreFactory", () => { expect(result.config).toBe(dummyVSConfig); }); + test("passes Neptune endpoint URI config through the factory", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune", config) as any; + + expect(store.config).toEqual(config); + }); + + test("keeps neptune-analytics as a compatibility alias", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune-analytics", config) as any; + + expect(store.config).toEqual(config); + }); + test("throws for unsupported provider", () => { expect(() => VectorStoreFactory.create("nonexistent", dummyVSConfig), diff --git a/mem0-ts/src/oss/tests/optional-peers.test.ts b/mem0-ts/src/oss/tests/optional-peers.test.ts new file mode 100644 index 000000000..a3ec460a1 --- /dev/null +++ b/mem0-ts/src/oss/tests/optional-peers.test.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync, statSync } from "fs"; +import { join, relative, resolve } from "path"; + +// Optional peers are not installed by npm/pnpm. A static value import of one therefore throws +// MODULE_NOT_FOUND the moment anything pulls in `mem0ai/oss`, because src/index.ts re-exports every +// vector store. Load them with `await import(...)` inside the code path that needs them instead. + +const packageRoot = resolve(__dirname, "../../.."); + +function optionalPeers(): string[] { + const pkg = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ); + return Object.entries( + (pkg.peerDependenciesMeta ?? {}) as Record, + ) + .filter(([, meta]) => meta.optional) + .map(([name]) => name); +} + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry !== "tests" && entry !== "__tests__") sourceFiles(full, acc); + } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) { + acc.push(full); + } + } + return acc; +} + +// Matches `import ... from "pkg"` and `import "pkg"`, but not `import type ... from "pkg"`, +// `typeof import("pkg")`, or `await import("pkg")` — those are erased or already lazy. +function hasStaticValueImport(pkg: string, source: string): boolean { + const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const specifier = `["']${escaped}(?:\\/[^"']*)?["']`; + return ( + new RegExp( + `(?:^|\\n)\\s*import\\s+(?!type\\b)[^;]*?from\\s+${specifier}`, + ).test(source) || + new RegExp(`(?:^|\\n)\\s*import\\s+${specifier}`).test(source) + ); +} + +describe("optional peer dependencies", () => { + const peers = optionalPeers(); + + it("are discoverable from package.json", () => { + expect(peers.length).toBeGreaterThan(0); + }); + + it("are never statically imported by src", () => { + const files = sourceFiles(join(packageRoot, "src")); + const sources = new Map( + files.map((file) => [file, readFileSync(file, "utf8")]), + ); + + const offenders: string[] = []; + for (const peer of peers) { + for (const [file, source] of sources) { + if (hasStaticValueImport(peer, source)) { + offenders.push(`${relative(packageRoot, file)} imports ${peer}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); 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 b923760a7..6ab87294f 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -1530,6 +1530,1270 @@ 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", () => { + afterEach(() => { + jest.dontMock("@aws-sdk/client-neptune-graph"); + jest.resetModules(); + }); + + function createMockResponse(body: Record) { + return { + payload: { + transformToString: jest.fn().mockResolvedValue(JSON.stringify(body)), + }, + }; + } + + function createMockNeptuneClient(options?: { + failInsertUpsert?: boolean; + throwInsertUpsert?: boolean; + failUpdateUpsert?: boolean; + failPayloadWrite?: boolean; + }) { + const nodes = new Map< + string, + { + embedding?: number[]; + labels: string[]; + properties: Record; + } + >(); + let storedUserId: string | undefined; + + const getPropertyValue = ( + node: { labels: string[]; properties: Record }, + property: string, + ) => { + if (property === "~label") { + return node.labels; + } + + return node.properties[property]; + }; + + const matchesFilter = ( + node: { labels: string[]; properties: Record }, + filter: any, + ): boolean => { + if (!filter) { + return true; + } + + if (Array.isArray(filter.andAll)) { + return filter.andAll.every((entry: any) => matchesFilter(node, entry)); + } + + if (Array.isArray(filter.orAll)) { + return filter.orAll.some((entry: any) => matchesFilter(node, entry)); + } + + const propertyMatcher = ( + property: string, + predicate: (value: any) => boolean, + ) => { + const value = getPropertyValue(node, property); + if (property === "~label") { + return ( + Array.isArray(value) && value.some((label) => predicate(label)) + ); + } + + return predicate(value); + }; + + if (filter.equals) { + return propertyMatcher( + filter.equals.property, + (value) => value === filter.equals.value, + ); + } + + if (filter.notEquals) { + return propertyMatcher( + filter.notEquals.property, + (value) => value !== filter.notEquals.value, + ); + } + + if (filter.greaterThan) { + return propertyMatcher( + filter.greaterThan.property, + (value) => + typeof value === "number" && value > filter.greaterThan.value, + ); + } + + if (filter.greaterThanOrEquals) { + return propertyMatcher( + filter.greaterThanOrEquals.property, + (value) => + typeof value === "number" && + value >= filter.greaterThanOrEquals.value, + ); + } + + if (filter.lessThan) { + return propertyMatcher( + filter.lessThan.property, + (value) => typeof value === "number" && value < filter.lessThan.value, + ); + } + + if (filter.lessThanOrEquals) { + return propertyMatcher( + filter.lessThanOrEquals.property, + (value) => + typeof value === "number" && value <= filter.lessThanOrEquals.value, + ); + } + + if (filter.in) { + return propertyMatcher(filter.in.property, (value) => + filter.in.value.includes(value), + ); + } + + if (filter.notIn) { + return propertyMatcher( + filter.notIn.property, + (value) => !filter.notIn.value.includes(value), + ); + } + + if (filter.stringContains) { + return propertyMatcher( + filter.stringContains.property, + (value) => + typeof value === "string" && + value.includes(filter.stringContains.value), + ); + } + + if (filter.startsWith) { + return propertyMatcher( + filter.startsWith.property, + (value) => + typeof value === "string" && + value.startsWith(filter.startsWith.value), + ); + } + + return false; + }; + + const toNodeRecord = ( + id: string, + node: { labels: string[]; properties: Record }, + ): Record => ({ + "~id": id, + "~labels": [...node.labels], + "~properties": { ...node.properties }, + }); + + const matchesListParameters = ( + properties: Record, + parameters: Record, + ) => + Object.entries(parameters) + .filter(([key]) => key.startsWith("filter_")) + .every(([key, value]) => { + const match = key.match(/^filter_(?:eq_)?(.+)_\d+$/); + if (!match) { + return true; + } + + return properties[match[1]] === value; + }); + + const extractStructuredArgument = ( + queryString: string, + key: string, + ): any => { + const keyIndex = queryString.indexOf(`${key}:`); + if (keyIndex < 0) { + return undefined; + } + + const objectStart = queryString.indexOf("{", keyIndex); + if (objectStart < 0) { + return undefined; + } + + let depth = 0; + for (let index = objectStart; index < queryString.length; index += 1) { + const char = queryString[index]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + const literal = queryString.slice(objectStart, index + 1); + const jsonLiteral = literal.replace( + /([{\[,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, + '$1"$2"$3', + ); + return JSON.parse(jsonLiteral); + } + } + } + + return undefined; + }; + + return { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + const parameters = command.input.parameters || {}; + const collectionLabelMatch = queryString.match(/MERGE \(n:`([^`]+)`/); + const collectionLabel = collectionLabelMatch?.[1] || "MEM0_VECTOR_test"; + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("RETURN nodeId") + ) { + return createMockResponse({ + results: (parameters.nodeIds || []) + .filter((nodeId: string) => nodes.has(nodeId)) + .map((nodeId: string) => ({ nodeId })), + }); + } + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("DETACH DELETE n") + ) { + for (const nodeId of parameters.nodeIds || []) { + nodes.delete(nodeId); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + Array.isArray(parameters.rows) && + queryString.includes("RETURN success") + ) { + const createsNode = queryString.includes("MERGE"); + const processedRows: any[] = []; + for (const row of parameters.rows) { + const existing = nodes.get(row.node_id); + if (!existing && !createsNode) { + continue; + } + nodes.set(row.node_id, { + embedding: row.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing ? existing.properties : {}, + }); + processedRows.push(row); + } + + if (options?.throwInsertUpsert) { + throw new Error("Neptune upsert rejected"); + } + + return createMockResponse({ + results: processedRows.map(() => ({ + success: !options?.failInsertUpsert, + })), + }); + } + + if ( + queryString.includes("UNWIND $rows AS row") && + queryString.includes("SET n += row.properties") + ) { + if (!options?.failPayloadWrite) { + for (const row of parameters.rows || []) { + const existing = nodes.get(row.node_id); + nodes.set(row.node_id, { + embedding: existing?.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing + ? { ...existing.properties, ...row.properties } + : { ...row.properties }, + }); + } + } + + return createMockResponse({ results: [{ n: {} }] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + parameters.vectorId + ) { + if (options?.failUpdateUpsert) { + return createMockResponse({ results: [{ success: false }] }); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: parameters.embedding, + labels: existing.labels, + properties: parameters.properties || existing.properties, + }); + } + return createMockResponse({ results: [{ success: true }] }); + } + + if (queryString.includes("topK.byEmbedding")) { + const vertexFilter = extractStructuredArgument( + queryString, + "vertexFilter", + ); + const match = [...nodes.entries()].find(([, node]) => + matchesFilter(node, vertexFilter), + ); + + return createMockResponse({ + results: match + ? [ + { + node: toNodeRecord(match[0], match[1]), + score: 0.25, + }, + ] + : [], + }); + } + + if (queryString.includes("pg_schema")) { + const labels = new Set(); + for (const node of nodes.values()) { + for (const label of node.labels) { + labels.add(label); + } + } + if (storedUserId) { + labels.add("MEM0_VECTOR_memory_migrations"); + } + return createMockResponse({ + results: [{ result: [...labels] }], + }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("RETURN n") && + queryString.includes("LIMIT 1") + ) { + const node = nodes.get(parameters.vectorId); + return createMockResponse({ + results: node + ? [{ n: toNodeRecord(parameters.vectorId, node) }] + : [], + }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN count(n) AS count") + ) { + const count = [...nodes.values()].filter((node) => + matchesListParameters(node.properties, parameters), + ).length; + + return createMockResponse({ results: [{ count }] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN n") && + queryString.includes("LIMIT $limit") + ) { + const results = [...nodes.entries()] + .filter(([, node]) => + matchesListParameters(node.properties, parameters), + ) + .slice(0, parameters.limit || 100) + .map(([id, node]) => ({ n: toNodeRecord(id, node) })); + + return createMockResponse({ results }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("SET n = $properties") + ) { + if (options?.failPayloadWrite) { + throw new Error("Neptune property write rejected"); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: existing.embedding, + labels: existing.labels, + properties: { ...parameters.properties }, + }); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("DETACH DELETE n") + ) { + nodes.delete(parameters.vectorId); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("DETACH DELETE n") + ) { + nodes.clear(); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("RETURN n") + ) { + return createMockResponse({ + results: storedUserId + ? [ + { + n: toNodeRecord(parameters.userNodeId, { + labels: ["MEM0_VECTOR_memory_migrations"], + properties: { + user_id: storedUserId, + }, + }), + }, + ] + : [], + }); + } + + if ( + queryString.includes("MERGE (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("SET n.user_id = $userId") + ) { + storedUserId = parameters.userId; + return createMockResponse({ results: [] }); + } + + return createMockResponse({ results: [{ success: true }] }); + }), + }; + } + + it("implements full VectorStore interface", () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + 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 { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + + expect(p1).toBe(p2); + expect(p2).toBe(p3); + + await Promise.all([p1, p2, p3]); + expect(mockClient.send).not.toHaveBeenCalled(); + }); + + it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", () => { + jest.resetModules(); + + const neptuneGraphClient = jest.fn().mockReturnValue({ + send: jest.fn(), + }); + + jest.doMock("@aws-sdk/client-neptune-graph", () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + region: "us-east-1", + profile: "dev-profile", + maxAttempts: 3, + }); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + maxAttempts: 3, + profile: "dev-profile", + region: "us-east-1", + }); + }); + + it("rejects HTTPS endpoints without an explicit graphIdentifier", () => { + jest.resetModules(); + + 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(), + }), + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + expect( + () => + new NeptuneAnalyticsVectorStore({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + }), + ).toThrow( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + }); + + it("derives graphIdentifier from a neptune-graph endpoint URI", async () => { + jest.resetModules(); + + 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; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + endpoint: "neptune-graph://g-1234567890", + collectionName: "test", + dimension: 3, + region: "us-east-1", + }); + + await store.search([1, 2, 3], 1); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + region: "us-east-1", + }); + expect(send).toHaveBeenCalled(); + expect(send.mock.calls[0][0].input.graphIdentifier).toBe("g-1234567890"); + }); + + it("shapes Neptune write requests and normalizes search results", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", label: "topic-a", priority: 7, user_id: "u1" }], + ); + + expect(mockClient.send).toHaveBeenCalled(); + const insertCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes("MERGE"), + ); + const vectorCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ); + expect(insertCall).toBeDefined(); + expect(insertCall.input.queryString).toContain("MERGE"); + expect(insertCall.input.queryString).not.toContain("FOREACH"); + expect(insertCall.input.parameters.rows[0].properties.label).toBe( + "topic-a", + ); + expect(insertCall.input.parameters.rows[0].embedding).toEqual([1, 2, 3]); + + expect(vectorCall).toBeDefined(); + expect(vectorCall.input.queryString).toContain( + "CALL neptune.algo.vectors.upsert", + ); + expect(vectorCall.input.queryString).toContain( + "WITH n, row.embedding AS embedding", + ); + expect(vectorCall.input.queryString).not.toContain("MERGE"); + + const results = await store.search([1, 2, 3], 1, { + $or: [{ user_id: "u2" }, { priority: { gte: 5 } }], + data: { contains: "alp" }, + }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + id: "id-1", + payload: expect.objectContaining({ + data: "alpha", + label: "topic-a", + user_id: "u1", + }), + score: 0.8, + }); + + const searchCall = + mockClient.send.mock.calls[mockClient.send.mock.calls.length - 1]; + const searchQuery = String(searchCall[0].input.queryString || "").replace( + /\s+/g, + " ", + ); + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchCall[0].input.parameters).toBeUndefined(); + expect(searchQuery).toContain("topK: 1"); + expect(searchQuery).toContain("embedding: [1, 2, 3]"); + expect(searchQuery).toContain( + 'property: "~label", value: "MEM0_VECTOR_test"', + ); + expect(searchQuery).toContain('property: "user_id", value: "u2"'); + expect(searchQuery).toContain('property: "priority", value: 5'); + expect(searchQuery).toContain( + 'stringContains: { property: "data", value: "alp" }', + ); + }); + + it("escapes adversarial Neptune collection and filter identifiers", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + if (queryString.includes("RETURN count(n) AS count")) { + return createMockResponse({ results: [{ count: 0 }] }); + } + + return createMockResponse({ results: [] }); + }), + }; + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient as any, + graphIdentifier: "g-1234567890", + collectionName: "memo`graph", + dimension: 3, + }); + + await store.list({ "topic`name": ["alpha`1", "beta(2)"] }, 2); + await store.search([1, 2, 3], 4, { + "topic`name": { eq: "lookup`value" }, + }); + + const listCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit"), + )?.[0]; + const searchCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("topK.byEmbedding"), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(searchCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const searchQuery = String(searchCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("MATCH (n:`MEM0_VECTOR_memo``graph`)"); + expect(listQuery).toContain("n.`topic``name` IN $filter_in_topic_name_1"); + expect(listCall.input.parameters.filter_in_topic_name_1).toEqual([ + "alpha`1", + "beta(2)", + ]); + + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchQuery).toContain("vertexFilter: {"); + expect(searchQuery).toContain('property: "topic`name"'); + expect(searchQuery).toContain('value: "lookup`value"'); + }); + + it("serializes complex list filters into Cypher clauses and parameters", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.list( + { + $and: [ + { + $or: [{ priority: { gte: 5 } }, { data: { startsWith: "alp" } }], + }, + { $not: [{ archived: true }] }, + { label: { contains: "topic" } }, + { tag: ["a", "b"] }, + { optional: "*" }, + ], + }, + 7, + ); + + const listCall = mockClient.send.mock.calls.find( + ([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit") && + String(command.input.queryString || "").includes("RETURN n"), + )?.[0]; + const countCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes( + "RETURN count(n) AS count", + ), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(countCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const countQuery = String(countCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("WHERE ("); + expect(listQuery).toContain("n.`priority` >= $filter_gte_priority_1"); + expect(listQuery).toContain( + "toString(n.`data`) STARTS WITH $filter_startsWith_data_2", + ); + expect(listQuery).toContain("NOT (n.`archived` = $filter_archived_3)"); + expect(listQuery).toContain( + "toString(n.`label`) CONTAINS $filter_contains_label_4", + ); + expect(listQuery).toContain("n.`tag` IN $filter_in_tag_5"); + expect(listQuery).toContain("n.`optional` IS NOT NULL"); + expect(countQuery).toContain("RETURN count(n) AS count"); + expect(listCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + limit: 7, + }); + expect(countCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + }); + }); + + it("replaces payloads on update and supports user-id storage", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + const created = await store.get("id-1"); + expect(created).not.toBeNull(); + expect(created!.payload).toEqual( + expect.objectContaining({ + data: "alpha", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updated = await store.get("id-1"); + expect(updated).not.toBeNull(); + expect(updated!.payload).toEqual( + expect.objectContaining({ + data: "beta", + user_id: "u1", + }), + ); + expect(updated!.payload.stale).toBeUndefined(); + + const combinedUpdateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("FOREACH"), + ), + ).toBe(false); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(count).toBe(1); + expect(listed[0].id).toBe("id-1"); + + const generatedUserId = await store.getUserId(); + expect(typeof generatedUserId).toBe("string"); + expect(generatedUserId.length).toBeGreaterThan(0); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + + await store.delete("id-1"); + expect(await store.get("id-1")).toBeNull(); + }); + + it("supports payload-only and vector-only Neptune updates", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + await store.update("id-1", [], { data: "payload-only", user_id: "u1" }); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], {}); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + const updateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + updateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + updateCalls.some( + (command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ) && !("properties" in (command.input.parameters || {})), + ), + ).toBe(true); + }); + + it("deletes the full Neptune collection with deleteCol()", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + await store.deleteCol(); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(listed).toEqual([]); + expect(count).toBe(0); + + const deleteColCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test`)", + ), + ); + expect(deleteColCall).toBeDefined(); + expect(deleteColCall.input.queryString).toContain("DETACH DELETE n"); + }); + + it("returns a real total count for list pagination", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + const [listed, count] = await store.list({ user_id: "u1" }, 1); + expect(listed).toHaveLength(1); + expect(count).toBe(2); + }); + + it("reads a persisted user id on a fresh store instance", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.setUserId("persisted-user"); + + const freshStore = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(await freshStore.getUserId()).toBe("persisted-user"); + }); + + it("throws when Neptune rejects an update upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failUpdateUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await expect( + 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. + const afterFailedUpsert = await store.get("id-1"); + expect(afterFailedUpsert).not.toBeNull(); + expect(afterFailedUpsert!.payload).toEqual( + expect.objectContaining({ + data: "beta", + user_id: "u1", + }), + ); + }); + + it("does not leave a phantom record when Neptune rejects an insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Insert failed in Neptune Analytics"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when Neptune throws during insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ throwInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Neptune upsert rejected"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when the property write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failPayloadWrite: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + const results = await store.search([1, 2, 3], 1); + expect(results).toEqual([]); + }); + + it("does not make the embedding durable when the update payload write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient({ failPayloadWrite: true }); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), + ).rejects.toThrow(); + + const upsertCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "neptune.algo.vectors.upsert", + ), + ); + expect(upsertCalls).toHaveLength(0); + }); + + it("writes the Neptune payload before the vector on a combined update", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updateQueries = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ) + .map((command: any) => String(command.input.queryString || "")); + + const payloadIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("SET n = $properties"), + ); + const vectorIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("CALL neptune.algo.vectors.upsert"), + ); + + expect(payloadIndex).toBeGreaterThanOrEqual(0); + expect(vectorIndex).toBeGreaterThan(payloadIndex); + }); + + it("throws for unsupported Neptune search and list filter shapes", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect(store.search([1, 2, 3], 1, { optional: "*" })).rejects.toThrow( + "Neptune Analytics vector search does not support property-existence filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { icontains: "alp" } }), + ).rejects.toThrow( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { regex: "alp" } }), + ).rejects.toThrow("Unsupported Neptune Analytics filter operator: regex"); + await expect( + store.search([1, 2, 3], 1, { + $not: [{ data: { contains: "alp" } }], + }), + ).rejects.toThrow( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { eq: () => "alp" } }), + ).rejects.toThrow( + "Unsupported Neptune Analytics algorithm value type: function", + ); + await expect(store.list({ data: { icontains: "alp" } })).rejects.toThrow( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + await expect(store.list({ data: { regex: "alp" } })).rejects.toThrow( + "Unsupported Neptune Analytics filter operator: regex", + ); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────── // 7. Vectorize — mock Cloudflare client, test idempotent init // ─────────────────────────────────────────────────────────────────────────── diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 6dd9d5b44..777308270 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -4,6 +4,7 @@ import pkg from "./package.json"; const external = [ "openai", "@anthropic-ai/sdk", + "@aws-sdk/client-neptune-graph", "@aws-sdk/client-s3vectors", "@mochow/mochow-sdk-node", "groq-sdk",