@mastra/couchbase 0.11.4 → 0.11.7-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +18 -5
- package/.turbo/turbo-build.log +0 -4
- package/docker-compose.yaml +0 -21
- package/eslint.config.js +0 -6
- package/scripts/start-docker.js +0 -14
- package/scripts/stop-docker.js +0 -7
- package/src/index.ts +0 -1
- package/src/vector/index.integration.test.ts +0 -733
- package/src/vector/index.ts +0 -504
- package/src/vector/index.unit.test.ts +0 -737
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -5
- package/tsup.config.ts +0 -17
- package/vitest.config.ts +0 -11
|
@@ -1,733 +0,0 @@
|
|
|
1
|
-
// Integration tests for CouchbaseVector
|
|
2
|
-
// IMPORTANT: These tests require Docker Engine to be running.
|
|
3
|
-
// The tests will automatically start and configure the required Couchbase container.
|
|
4
|
-
|
|
5
|
-
import { execSync } from 'child_process';
|
|
6
|
-
import { randomUUID } from 'crypto';
|
|
7
|
-
import axios from 'axios';
|
|
8
|
-
import type { Cluster, Bucket, Scope, Collection } from 'couchbase';
|
|
9
|
-
import { connect } from 'couchbase';
|
|
10
|
-
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
11
|
-
import { CouchbaseVector, DISTANCE_MAPPING } from './index';
|
|
12
|
-
|
|
13
|
-
const containerName = 'mastra_couchbase_testing';
|
|
14
|
-
|
|
15
|
-
const connectionString = 'couchbase://localhost';
|
|
16
|
-
const username = 'Administrator';
|
|
17
|
-
const password = 'password';
|
|
18
|
-
|
|
19
|
-
const dimension = 3;
|
|
20
|
-
const test_bucketName = 'test-bucket';
|
|
21
|
-
const test_scopeName = 'test-scope';
|
|
22
|
-
const test_collectionName = 'test-collection';
|
|
23
|
-
const test_indexName = 'test-index';
|
|
24
|
-
|
|
25
|
-
async function setupCluster() {
|
|
26
|
-
try {
|
|
27
|
-
// Initialize the cluster
|
|
28
|
-
execSync(
|
|
29
|
-
`docker exec -i ${containerName} couchbase-cli cluster-init --cluster "${connectionString}" \
|
|
30
|
-
--cluster-username "${username}" --cluster-password "${password}" --cluster-ramsize 512 \
|
|
31
|
-
--cluster-index-ramsize 512 --cluster-fts-ramsize 512 --services data,index,query,fts`,
|
|
32
|
-
{ stdio: 'inherit' },
|
|
33
|
-
);
|
|
34
|
-
} catch (error) {
|
|
35
|
-
console.error('Error initializing Couchbase cluster:', error.message);
|
|
36
|
-
// Decide if you want to re-throw or handle specific errors here
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
// Create the bucket
|
|
41
|
-
execSync(
|
|
42
|
-
`docker exec -i ${containerName} couchbase-cli bucket-create -c "${connectionString}" \
|
|
43
|
-
--username "${username}" --password "${password}" \
|
|
44
|
-
--bucket "${test_bucketName}" --bucket-type couchbase --bucket-ramsize 200`,
|
|
45
|
-
{ stdio: 'inherit' },
|
|
46
|
-
);
|
|
47
|
-
} catch (error) {
|
|
48
|
-
console.error('Error creating bucket:', error.message);
|
|
49
|
-
// Decide if you want to re-throw or handle specific errors here
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Wait for cluster to be fully available after potential operations
|
|
53
|
-
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function checkBucketHealth(
|
|
57
|
-
connectionString: string,
|
|
58
|
-
username: string,
|
|
59
|
-
password: string,
|
|
60
|
-
bucketName: string,
|
|
61
|
-
): Promise<void> {
|
|
62
|
-
const maxAttempts = 20;
|
|
63
|
-
let attempt = 0;
|
|
64
|
-
|
|
65
|
-
// Parse the connection string to get the host
|
|
66
|
-
const parsedUrl = new URL(connectionString);
|
|
67
|
-
const host = parsedUrl.hostname;
|
|
68
|
-
const url = `http://${host}:8091/pools/default/buckets/${bucketName}`;
|
|
69
|
-
|
|
70
|
-
while (attempt < maxAttempts) {
|
|
71
|
-
try {
|
|
72
|
-
const response = await axios.get(url, {
|
|
73
|
-
auth: {
|
|
74
|
-
username,
|
|
75
|
-
password,
|
|
76
|
-
},
|
|
77
|
-
validateStatus: () => true, // Don't throw on any status code
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const responseData = response.data;
|
|
81
|
-
if (
|
|
82
|
-
response.status === 200 &&
|
|
83
|
-
responseData.nodes &&
|
|
84
|
-
responseData.nodes.length > 0 &&
|
|
85
|
-
responseData.nodes[0].status === 'healthy'
|
|
86
|
-
) {
|
|
87
|
-
return;
|
|
88
|
-
} else {
|
|
89
|
-
console.log(`Attempt ${attempt + 1}/${maxAttempts}: Bucket '${bucketName}' health check failed`);
|
|
90
|
-
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3 seconds
|
|
91
|
-
attempt++;
|
|
92
|
-
}
|
|
93
|
-
} catch (error) {
|
|
94
|
-
console.log(
|
|
95
|
-
`Attempt ${attempt + 1}/${maxAttempts}: Bucket '${bucketName}' health check failed with error: ${error.message}`,
|
|
96
|
-
);
|
|
97
|
-
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3 seconds
|
|
98
|
-
attempt++;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
throw new Error(`Bucket '${bucketName}' health check failed after ${maxAttempts} attempts.`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
describe('Integration Testing CouchbaseVector', async () => {
|
|
106
|
-
// Use Couchbase Enterprise 7.6+ which supports vector search
|
|
107
|
-
let couchbase_client: CouchbaseVector;
|
|
108
|
-
let cluster: Cluster;
|
|
109
|
-
let bucket: Bucket;
|
|
110
|
-
let scope: Scope;
|
|
111
|
-
let collection: Collection;
|
|
112
|
-
|
|
113
|
-
beforeAll(
|
|
114
|
-
async () => {
|
|
115
|
-
try {
|
|
116
|
-
// Initialize the cluster
|
|
117
|
-
await setupCluster();
|
|
118
|
-
|
|
119
|
-
// Check cluster health before trying to connect
|
|
120
|
-
await checkBucketHealth(connectionString, username, password, test_bucketName);
|
|
121
|
-
|
|
122
|
-
// Connect to the cluster
|
|
123
|
-
cluster = await connect(connectionString, {
|
|
124
|
-
username: username,
|
|
125
|
-
password: password,
|
|
126
|
-
configProfile: 'wanDevelopment',
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// If bucket is not there, then create it
|
|
130
|
-
const bucketmanager = cluster.buckets();
|
|
131
|
-
try {
|
|
132
|
-
await bucketmanager.getBucket(test_bucketName);
|
|
133
|
-
} catch (e) {
|
|
134
|
-
if (e.message.includes('not found')) {
|
|
135
|
-
await bucketmanager.createBucket({
|
|
136
|
-
name: test_bucketName,
|
|
137
|
-
ramQuotaMB: 100,
|
|
138
|
-
numReplicas: 0,
|
|
139
|
-
});
|
|
140
|
-
} else {
|
|
141
|
-
throw e;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
bucket = cluster.bucket(test_bucketName);
|
|
145
|
-
|
|
146
|
-
// If scope or collection are not there, then create it
|
|
147
|
-
const all_scopes = await bucket.collections().getAllScopes();
|
|
148
|
-
const scope_info = all_scopes.find(scope => scope.name === test_scopeName);
|
|
149
|
-
if (!scope_info) {
|
|
150
|
-
await bucket.collections().createScope(test_scopeName);
|
|
151
|
-
scope = bucket.scope(test_scopeName);
|
|
152
|
-
await bucket.collections().createCollection(test_collectionName, test_scopeName);
|
|
153
|
-
collection = scope.collection(test_collectionName);
|
|
154
|
-
} else {
|
|
155
|
-
scope = bucket.scope(test_scopeName);
|
|
156
|
-
if (!scope_info.collections.some(collection => collection.name === test_collectionName)) {
|
|
157
|
-
await bucket.collections().createCollection(test_collectionName, test_scopeName);
|
|
158
|
-
}
|
|
159
|
-
collection = scope.collection(test_collectionName);
|
|
160
|
-
}
|
|
161
|
-
} catch (error) {
|
|
162
|
-
console.error('Failed to start Couchbase container:', error);
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
5 * 60 * 1000,
|
|
166
|
-
); // 5 minutes
|
|
167
|
-
|
|
168
|
-
afterAll(async () => {
|
|
169
|
-
if (cluster) {
|
|
170
|
-
await cluster.close();
|
|
171
|
-
}
|
|
172
|
-
}, 50000);
|
|
173
|
-
|
|
174
|
-
describe('Connection', () => {
|
|
175
|
-
it('should connect to couchbase', async () => {
|
|
176
|
-
couchbase_client = new CouchbaseVector({
|
|
177
|
-
connectionString,
|
|
178
|
-
username,
|
|
179
|
-
password,
|
|
180
|
-
bucketName: test_bucketName,
|
|
181
|
-
scopeName: test_scopeName,
|
|
182
|
-
collectionName: test_collectionName,
|
|
183
|
-
});
|
|
184
|
-
expect(couchbase_client).toBeDefined();
|
|
185
|
-
const collection = await couchbase_client.getCollection();
|
|
186
|
-
expect(collection).toBeDefined();
|
|
187
|
-
}, 50000);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe('Index Operations', () => {
|
|
191
|
-
it('should create index', async () => {
|
|
192
|
-
await couchbase_client.createIndex({ indexName: test_indexName, dimension, metric: 'euclidean' });
|
|
193
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
194
|
-
|
|
195
|
-
const index_definition = await scope.searchIndexes().getIndex(test_indexName);
|
|
196
|
-
expect(index_definition).toBeDefined();
|
|
197
|
-
expect(index_definition.name).toBe(test_indexName);
|
|
198
|
-
expect(
|
|
199
|
-
index_definition.params.mapping?.types?.[`${test_scopeName}.${test_collectionName}`]?.properties?.embedding
|
|
200
|
-
?.fields?.[0]?.dims,
|
|
201
|
-
).toBe(dimension);
|
|
202
|
-
expect(
|
|
203
|
-
index_definition.params.mapping?.types?.[`${test_scopeName}.${test_collectionName}`]?.properties?.embedding
|
|
204
|
-
?.fields?.[0]?.similarity,
|
|
205
|
-
).toBe('l2_norm'); // similiarity(=="l2_norm") is mapped to euclidean in couchbase
|
|
206
|
-
}, 50000);
|
|
207
|
-
|
|
208
|
-
it('should list indexes', async () => {
|
|
209
|
-
const indexes = await couchbase_client.listIndexes();
|
|
210
|
-
expect(indexes).toContain(test_indexName);
|
|
211
|
-
}, 50000);
|
|
212
|
-
|
|
213
|
-
it('should describe index', async () => {
|
|
214
|
-
const stats = await couchbase_client.describeIndex({ indexName: test_indexName });
|
|
215
|
-
expect(stats.dimension).toBe(dimension);
|
|
216
|
-
expect(stats.metric).toBe('euclidean'); // similiarity(=="l2_norm") is mapped to euclidean in couchbase
|
|
217
|
-
expect(typeof stats.count).toBe('number');
|
|
218
|
-
}, 50000);
|
|
219
|
-
|
|
220
|
-
it('should delete index', async () => {
|
|
221
|
-
await couchbase_client.deleteIndex({ indexName: test_indexName });
|
|
222
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
223
|
-
await expect(scope.searchIndexes().getIndex(test_indexName)).rejects.toThrowError();
|
|
224
|
-
}, 50000);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
describe('Vector Operations', () => {
|
|
228
|
-
const testVectors = [
|
|
229
|
-
[1.0, 0.0, 0.0],
|
|
230
|
-
[0.0, 1.0, 0.0],
|
|
231
|
-
[0.0, 0.0, 1.0],
|
|
232
|
-
];
|
|
233
|
-
const testMetadata = [
|
|
234
|
-
{ label: 'x-axis' },
|
|
235
|
-
{
|
|
236
|
-
label: 'y-axis',
|
|
237
|
-
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
|
238
|
-
},
|
|
239
|
-
{ label: 'z-axis' },
|
|
240
|
-
];
|
|
241
|
-
let testVectorIds: string[] = ['test_id_1', 'test_id_2', 'test_id_3'];
|
|
242
|
-
|
|
243
|
-
beforeAll(async () => {
|
|
244
|
-
await couchbase_client.createIndex({ indexName: test_indexName, dimension, metric: 'euclidean' });
|
|
245
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
246
|
-
}, 50000);
|
|
247
|
-
|
|
248
|
-
afterAll(async () => {
|
|
249
|
-
await couchbase_client.deleteIndex({ indexName: test_indexName });
|
|
250
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
251
|
-
}, 50000);
|
|
252
|
-
|
|
253
|
-
it('should upsert vectors with metadata', async () => {
|
|
254
|
-
// Use the couchbase_client to upsert vectors
|
|
255
|
-
const vectorIds = await couchbase_client.upsert({
|
|
256
|
-
indexName: test_indexName,
|
|
257
|
-
vectors: testVectors,
|
|
258
|
-
metadata: testMetadata,
|
|
259
|
-
ids: testVectorIds,
|
|
260
|
-
});
|
|
261
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
262
|
-
|
|
263
|
-
// Verify vectors were stored correctly by retrieving them directly through the collection
|
|
264
|
-
for (let i = 0; i < 3; i++) {
|
|
265
|
-
const result = await collection.get(vectorIds[i]);
|
|
266
|
-
expect(result.content).toHaveProperty('embedding');
|
|
267
|
-
expect(result.content).toHaveProperty('metadata');
|
|
268
|
-
expect(result.content.embedding).toEqual(testVectors[i]);
|
|
269
|
-
expect(result.content.metadata).toEqual(testMetadata[i]);
|
|
270
|
-
|
|
271
|
-
// Check if content field was added for text field
|
|
272
|
-
if (testMetadata[i].text) {
|
|
273
|
-
expect(result.content).toHaveProperty('content');
|
|
274
|
-
expect(result.content.content).toEqual(testMetadata[i].text);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
expect(vectorIds).toHaveLength(3);
|
|
279
|
-
expect(vectorIds[0]).toBeDefined();
|
|
280
|
-
expect(vectorIds[1]).toBeDefined();
|
|
281
|
-
expect(vectorIds[2]).toBeDefined();
|
|
282
|
-
}, 50000);
|
|
283
|
-
|
|
284
|
-
it('should query vectors and return nearest neighbors', async () => {
|
|
285
|
-
const queryVector = [1.0, 0.1, 0.1];
|
|
286
|
-
const topK = 3;
|
|
287
|
-
|
|
288
|
-
const results = await couchbase_client.query({
|
|
289
|
-
indexName: test_indexName,
|
|
290
|
-
queryVector,
|
|
291
|
-
topK,
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// Verify results
|
|
295
|
-
expect(results).toHaveLength(topK);
|
|
296
|
-
|
|
297
|
-
// Check each result has expected properties
|
|
298
|
-
for (let i = 0; i < results.length; i++) {
|
|
299
|
-
const result = results[i];
|
|
300
|
-
// Find the index of this ID in the testVectorIds array
|
|
301
|
-
const originalIndex = testVectorIds.indexOf(result.id);
|
|
302
|
-
expect(originalIndex).not.toBe(-1); // Ensure we found the ID
|
|
303
|
-
|
|
304
|
-
const expectedMetadata = testMetadata[originalIndex];
|
|
305
|
-
const returnedMetadata = { ...result.metadata }; // Create a copy to avoid modifying the original
|
|
306
|
-
|
|
307
|
-
// Check if 'content' field exists and matches if 'text' was in original metadata
|
|
308
|
-
if (expectedMetadata.text) {
|
|
309
|
-
expect(returnedMetadata).toHaveProperty('content');
|
|
310
|
-
expect(returnedMetadata.content).toEqual(expectedMetadata.text);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// If the original metadata had a 'text' field, the returned metadata might include a 'content' field from the search index.
|
|
314
|
-
// We only want to compare the original metadata fields, so remove 'content' if it's present in the returned data
|
|
315
|
-
// and the original metadata had a 'text' field (which implies 'content' was likely added automatically).
|
|
316
|
-
if (expectedMetadata.text && returnedMetadata.content) {
|
|
317
|
-
delete returnedMetadata.content;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
expect(result).toHaveProperty('id');
|
|
321
|
-
expect(result).toHaveProperty('score');
|
|
322
|
-
expect(result).toHaveProperty('metadata');
|
|
323
|
-
expect(typeof result.score).toBe('number');
|
|
324
|
-
expect(returnedMetadata).toEqual(expectedMetadata); // Compare potentially modified returned metadata
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// The first result should be the most similar to the query vector
|
|
328
|
-
// In this case, it should be the X-axis vector [1,0,0] since our query is [1.0,0.1,0.1]
|
|
329
|
-
const firstResult = await collection.get(results[0].id);
|
|
330
|
-
expect(firstResult.content.embedding[0]).toBeCloseTo(1.0, 1);
|
|
331
|
-
}, 50000);
|
|
332
|
-
|
|
333
|
-
it('should update the vector by id', async () => {
|
|
334
|
-
// Use specific IDs for upsert
|
|
335
|
-
const new_vectors = [
|
|
336
|
-
[2, 1, 3],
|
|
337
|
-
[34, 1, 12],
|
|
338
|
-
[22, 23, 1],
|
|
339
|
-
];
|
|
340
|
-
const vectorIds = await couchbase_client.upsert({
|
|
341
|
-
indexName: test_indexName,
|
|
342
|
-
vectors: new_vectors,
|
|
343
|
-
metadata: testMetadata,
|
|
344
|
-
ids: testVectorIds,
|
|
345
|
-
});
|
|
346
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
347
|
-
|
|
348
|
-
// Verify the IDs match what we requested
|
|
349
|
-
expect(vectorIds).toEqual(testVectorIds);
|
|
350
|
-
|
|
351
|
-
// Verify each document was stored with the right data
|
|
352
|
-
for (let i = 0; i < testVectorIds.length; i++) {
|
|
353
|
-
const result = await collection.get(testVectorIds[i]);
|
|
354
|
-
expect(result.content.embedding).toEqual(new_vectors[i]);
|
|
355
|
-
expect(result.content.metadata).toEqual(testMetadata[i]);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Delete the vectors form the collection for further tests to run smoothly
|
|
359
|
-
for (let i = 0; i < testVectorIds.length; i++) {
|
|
360
|
-
await collection.remove(testVectorIds[i]);
|
|
361
|
-
}
|
|
362
|
-
}, 50000);
|
|
363
|
-
|
|
364
|
-
it('should throw error for invalid vector dimension', async () => {
|
|
365
|
-
await expect(
|
|
366
|
-
couchbase_client.upsert({
|
|
367
|
-
indexName: test_indexName,
|
|
368
|
-
vectors: [[1, 2, 3, 4]], // 4 dimensions instead of 3
|
|
369
|
-
metadata: [{ test: 'initial' }],
|
|
370
|
-
}),
|
|
371
|
-
).rejects.toThrow();
|
|
372
|
-
}, 50000);
|
|
373
|
-
|
|
374
|
-
it('should throw error when includeVector is true in query', async () => {
|
|
375
|
-
await expect(
|
|
376
|
-
couchbase_client.query({
|
|
377
|
-
indexName: test_indexName,
|
|
378
|
-
queryVector: [1.0, 2.0, 3.0],
|
|
379
|
-
includeVector: true,
|
|
380
|
-
}),
|
|
381
|
-
).rejects.toThrow('Including vectors in search results is not yet supported by the Couchbase vector store');
|
|
382
|
-
}, 50000);
|
|
383
|
-
|
|
384
|
-
it('should upsert vectors with generated ids', async () => {
|
|
385
|
-
const ids = await couchbase_client.upsert({ indexName: test_indexName, vectors: testVectors });
|
|
386
|
-
expect(ids).toHaveLength(testVectors.length);
|
|
387
|
-
ids.forEach(id => expect(typeof id).toBe('string'));
|
|
388
|
-
|
|
389
|
-
// Count is not supported by Couchbase
|
|
390
|
-
const stats = await couchbase_client.describeIndex({ indexName: test_indexName });
|
|
391
|
-
expect(stats.count).toBe(-1);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it('should update existing vectors', async () => {
|
|
395
|
-
// Initial upsert
|
|
396
|
-
await couchbase_client.upsert({
|
|
397
|
-
indexName: test_indexName,
|
|
398
|
-
vectors: testVectors,
|
|
399
|
-
metadata: testMetadata,
|
|
400
|
-
ids: testVectorIds,
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Update first vector
|
|
404
|
-
const updatedVector = [[0.5, 0.5, 0.0]];
|
|
405
|
-
const updatedMetadata = [{ label: 'updated-x-axis' }];
|
|
406
|
-
await couchbase_client.upsert({
|
|
407
|
-
indexName: test_indexName,
|
|
408
|
-
vectors: updatedVector,
|
|
409
|
-
metadata: updatedMetadata,
|
|
410
|
-
ids: [testVectorIds?.[0]!],
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// Verify update
|
|
414
|
-
const result = await collection.get(testVectorIds?.[0]!);
|
|
415
|
-
expect(result.content.embedding).toEqual(updatedVector[0]);
|
|
416
|
-
expect(result.content.metadata).toEqual(updatedMetadata[0]);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('should update the vector by id', async () => {
|
|
420
|
-
const ids = await couchbase_client.upsert({ indexName: test_indexName, vectors: testVectors });
|
|
421
|
-
expect(ids).toHaveLength(3);
|
|
422
|
-
|
|
423
|
-
const idToBeUpdated = ids[0];
|
|
424
|
-
const newVector = [1, 2, 3];
|
|
425
|
-
const newMetaData = {
|
|
426
|
-
test: 'updates',
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
const update = {
|
|
430
|
-
vector: newVector,
|
|
431
|
-
metadata: newMetaData,
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
await couchbase_client.updateVector({ indexName: test_indexName, id: idToBeUpdated, update });
|
|
435
|
-
|
|
436
|
-
const result = await collection.get(idToBeUpdated);
|
|
437
|
-
expect(result.content.embedding).toEqual(newVector);
|
|
438
|
-
expect(result.content.metadata).toEqual(newMetaData);
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it('should only update the metadata by id', async () => {
|
|
442
|
-
const ids = await couchbase_client.upsert({ indexName: test_indexName, vectors: testVectors });
|
|
443
|
-
expect(ids).toHaveLength(3);
|
|
444
|
-
|
|
445
|
-
const idToBeUpdated = ids[0];
|
|
446
|
-
const newMetaData = {
|
|
447
|
-
test: 'updates',
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const update = {
|
|
451
|
-
metadata: newMetaData,
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
await couchbase_client.updateVector({ indexName: test_indexName, id: idToBeUpdated, update });
|
|
455
|
-
|
|
456
|
-
const result = await collection.get(idToBeUpdated);
|
|
457
|
-
expect(result.content.embedding).toEqual(testVectors[0]);
|
|
458
|
-
expect(result.content.metadata).toEqual(newMetaData);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('should only update vector embeddings by id', async () => {
|
|
462
|
-
const ids = await couchbase_client.upsert({ indexName: test_indexName, vectors: testVectors });
|
|
463
|
-
expect(ids).toHaveLength(3);
|
|
464
|
-
|
|
465
|
-
const idToBeUpdated = ids[0];
|
|
466
|
-
const newVector = [1, 2, 3];
|
|
467
|
-
|
|
468
|
-
const update = {
|
|
469
|
-
vector: newVector,
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
await couchbase_client.updateVector({ indexName: test_indexName, id: idToBeUpdated, update });
|
|
473
|
-
|
|
474
|
-
const result = await collection.get(idToBeUpdated);
|
|
475
|
-
expect(result.content.embedding).toEqual(newVector);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it('should throw exception when no updates are given', async () => {
|
|
479
|
-
await expect(couchbase_client.updateVector({ indexName: test_indexName, id: 'id', update: {} })).rejects.toThrow(
|
|
480
|
-
'No updates provided',
|
|
481
|
-
);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
it('should delete the vector by id', async () => {
|
|
485
|
-
const ids = await couchbase_client.upsert({ indexName: test_indexName, vectors: testVectors });
|
|
486
|
-
expect(ids).toHaveLength(3);
|
|
487
|
-
const idToBeDeleted = ids[0];
|
|
488
|
-
|
|
489
|
-
await couchbase_client.deleteVector({ indexName: test_indexName, id: idToBeDeleted });
|
|
490
|
-
|
|
491
|
-
try {
|
|
492
|
-
await collection.get(idToBeDeleted);
|
|
493
|
-
} catch (error) {
|
|
494
|
-
expect(error).toBeInstanceOf(Error);
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
describe('Error Cases and Edge Cases', () => {
|
|
500
|
-
it('should throw error for negative dimension in createIndex', async () => {
|
|
501
|
-
await expect(
|
|
502
|
-
couchbase_client.createIndex({
|
|
503
|
-
indexName: `${test_indexName}_neg`,
|
|
504
|
-
dimension: -1,
|
|
505
|
-
}),
|
|
506
|
-
).rejects.toThrow('Dimension must be a positive integer');
|
|
507
|
-
}, 50000);
|
|
508
|
-
|
|
509
|
-
it('should throw error for zero dimension in createIndex', async () => {
|
|
510
|
-
await expect(
|
|
511
|
-
couchbase_client.createIndex({
|
|
512
|
-
indexName: `${test_indexName}_zero`,
|
|
513
|
-
dimension: 0,
|
|
514
|
-
}),
|
|
515
|
-
).rejects.toThrow('Dimension must be a positive integer');
|
|
516
|
-
}, 50000);
|
|
517
|
-
|
|
518
|
-
it('should throw error when describing a non-existent index', async () => {
|
|
519
|
-
const nonExistentIndex = 'non_existent_index';
|
|
520
|
-
|
|
521
|
-
// Verify the index doesn't exist using cluster API
|
|
522
|
-
const allIndexes = await scope.searchIndexes().getAllIndexes();
|
|
523
|
-
expect(allIndexes.find(idx => idx.name === nonExistentIndex)).toBeUndefined();
|
|
524
|
-
|
|
525
|
-
// Now test the couchbase_client method
|
|
526
|
-
await expect(couchbase_client.describeIndex({ indexName: nonExistentIndex })).rejects.toThrow();
|
|
527
|
-
}, 50000);
|
|
528
|
-
|
|
529
|
-
it('should throw error when deleting a non-existent index', async () => {
|
|
530
|
-
const nonExistentIndex = 'non_existent_index';
|
|
531
|
-
|
|
532
|
-
// Verify the index doesn't exist using cluster API
|
|
533
|
-
const allIndexes = await scope.searchIndexes().getAllIndexes();
|
|
534
|
-
expect(allIndexes.find(idx => idx.name === nonExistentIndex)).toBeUndefined();
|
|
535
|
-
|
|
536
|
-
// Now test the couchbase_client method
|
|
537
|
-
await expect(couchbase_client.deleteIndex({ indexName: nonExistentIndex })).rejects.toThrow();
|
|
538
|
-
}, 50000);
|
|
539
|
-
|
|
540
|
-
it('should throw error for empty vectors array in upsert', async () => {
|
|
541
|
-
await expect(
|
|
542
|
-
couchbase_client.upsert({
|
|
543
|
-
indexName: test_indexName,
|
|
544
|
-
vectors: [],
|
|
545
|
-
metadata: [],
|
|
546
|
-
}),
|
|
547
|
-
).rejects.toThrow('No vectors provided');
|
|
548
|
-
}, 50000);
|
|
549
|
-
|
|
550
|
-
it('should handle non-existent index queries', async () => {
|
|
551
|
-
await expect(
|
|
552
|
-
couchbase_client.query({ indexName: 'non-existent-index', queryVector: [1, 2, 3] }),
|
|
553
|
-
).rejects.toThrow();
|
|
554
|
-
}, 50000);
|
|
555
|
-
|
|
556
|
-
it('should handle duplicate index creation gracefully', async () => {
|
|
557
|
-
const duplicateIndexName = `duplicate-test-${randomUUID()}`;
|
|
558
|
-
const dimension = 768;
|
|
559
|
-
const infoSpy = vi.spyOn(couchbase_client['logger'], 'info');
|
|
560
|
-
const warnSpy = vi.spyOn(couchbase_client['logger'], 'warn');
|
|
561
|
-
|
|
562
|
-
try {
|
|
563
|
-
// Create index first time
|
|
564
|
-
await couchbase_client.createIndex({
|
|
565
|
-
indexName: duplicateIndexName,
|
|
566
|
-
dimension,
|
|
567
|
-
metric: 'cosine',
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
// Try to create with same dimensions - should not throw
|
|
571
|
-
await expect(
|
|
572
|
-
couchbase_client.createIndex({
|
|
573
|
-
indexName: duplicateIndexName,
|
|
574
|
-
dimension,
|
|
575
|
-
metric: 'cosine',
|
|
576
|
-
}),
|
|
577
|
-
).resolves.not.toThrow();
|
|
578
|
-
|
|
579
|
-
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('already exists with'));
|
|
580
|
-
|
|
581
|
-
// Try to create with same dimensions and different metric - should not throw
|
|
582
|
-
await expect(
|
|
583
|
-
couchbase_client.createIndex({
|
|
584
|
-
indexName: duplicateIndexName,
|
|
585
|
-
dimension,
|
|
586
|
-
metric: 'euclidean',
|
|
587
|
-
}),
|
|
588
|
-
).resolves.not.toThrow();
|
|
589
|
-
|
|
590
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Attempted to create index with metric'));
|
|
591
|
-
|
|
592
|
-
// Try to create with different dimensions - should throw
|
|
593
|
-
await expect(
|
|
594
|
-
couchbase_client.createIndex({
|
|
595
|
-
indexName: duplicateIndexName,
|
|
596
|
-
dimension: dimension + 1,
|
|
597
|
-
metric: 'cosine',
|
|
598
|
-
}),
|
|
599
|
-
).rejects.toThrow(
|
|
600
|
-
`Index "${duplicateIndexName}" already exists with ${dimension} dimensions, but ${dimension + 1} dimensions were requested`,
|
|
601
|
-
);
|
|
602
|
-
} finally {
|
|
603
|
-
infoSpy.mockRestore();
|
|
604
|
-
warnSpy.mockRestore();
|
|
605
|
-
// Cleanup
|
|
606
|
-
await couchbase_client.deleteIndex({ indexName: duplicateIndexName });
|
|
607
|
-
}
|
|
608
|
-
}, 50000);
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
describe('Vector Dimension Tracking', () => {
|
|
612
|
-
beforeAll(async () => {
|
|
613
|
-
const indexes = await couchbase_client.listIndexes();
|
|
614
|
-
if (indexes.length > 0) {
|
|
615
|
-
for (const index of indexes) {
|
|
616
|
-
await couchbase_client.deleteIndex({ indexName: index });
|
|
617
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}, 50000);
|
|
621
|
-
|
|
622
|
-
const testIndexName = `${test_indexName}_dim_tracking`;
|
|
623
|
-
const testDimension = 5;
|
|
624
|
-
|
|
625
|
-
it('should track vector dimension after creating an index', async () => {
|
|
626
|
-
// Initial vector_dimension should be null
|
|
627
|
-
expect((couchbase_client as any).vector_dimension).toBeNull();
|
|
628
|
-
|
|
629
|
-
// After creating index, vector_dimension should be set
|
|
630
|
-
await couchbase_client.createIndex({
|
|
631
|
-
indexName: testIndexName,
|
|
632
|
-
dimension: testDimension,
|
|
633
|
-
});
|
|
634
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
635
|
-
|
|
636
|
-
// Check internal property
|
|
637
|
-
expect((couchbase_client as any).vector_dimension).toBe(testDimension);
|
|
638
|
-
|
|
639
|
-
// Also verify through index description
|
|
640
|
-
const indexDef = await scope.searchIndexes().getIndex(testIndexName);
|
|
641
|
-
expect(
|
|
642
|
-
indexDef.params.mapping?.types?.[`${test_scopeName}.${test_collectionName}`]?.properties?.embedding?.fields?.[0]
|
|
643
|
-
?.dims,
|
|
644
|
-
).toBe(testDimension);
|
|
645
|
-
}, 50000);
|
|
646
|
-
|
|
647
|
-
it('should validate vector dimensions against tracked dimension during upsert', async () => {
|
|
648
|
-
// Should succeed with correct dimensions
|
|
649
|
-
const vectorIds = await couchbase_client.upsert({
|
|
650
|
-
indexName: testIndexName,
|
|
651
|
-
vectors: [
|
|
652
|
-
[1, 2, 3, 4, 5],
|
|
653
|
-
[4, 5, 6, 7, 8],
|
|
654
|
-
],
|
|
655
|
-
metadata: [{}, {}],
|
|
656
|
-
});
|
|
657
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
658
|
-
|
|
659
|
-
// Verify vectors were inserted with correct dimensions
|
|
660
|
-
for (const id of vectorIds) {
|
|
661
|
-
const result = await collection.get(id);
|
|
662
|
-
expect(result.content.embedding.length).toBe(testDimension);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Should fail with incorrect dimensions
|
|
666
|
-
await expect(
|
|
667
|
-
couchbase_client.upsert({
|
|
668
|
-
indexName: testIndexName,
|
|
669
|
-
vectors: [[1, 2, 3, 4]], // 4 dimensions instead of 5
|
|
670
|
-
metadata: [{}],
|
|
671
|
-
}),
|
|
672
|
-
).rejects.toThrow('Vector dimension mismatch');
|
|
673
|
-
}, 50000);
|
|
674
|
-
|
|
675
|
-
it('should reset vector_dimension when deleting an index', async () => {
|
|
676
|
-
expect((couchbase_client as any).vector_dimension).toBe(testDimension);
|
|
677
|
-
|
|
678
|
-
// Delete the index
|
|
679
|
-
await couchbase_client.deleteIndex({ indexName: testIndexName });
|
|
680
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
681
|
-
|
|
682
|
-
// Verify dimension is reset
|
|
683
|
-
expect((couchbase_client as any).vector_dimension).toBeNull();
|
|
684
|
-
|
|
685
|
-
// Also verify the index is gone using cluster directly
|
|
686
|
-
await expect(scope.searchIndexes().getIndex(testIndexName)).rejects.toThrow();
|
|
687
|
-
}, 50000);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
describe('Implementation Details', () => {
|
|
691
|
-
beforeAll(async () => {
|
|
692
|
-
const indexes = await couchbase_client.listIndexes();
|
|
693
|
-
if (indexes.length > 0) {
|
|
694
|
-
for (const index of indexes) {
|
|
695
|
-
await couchbase_client.deleteIndex({ indexName: index });
|
|
696
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}, 50000);
|
|
700
|
-
|
|
701
|
-
it('should handle metric mapping correctly', async () => {
|
|
702
|
-
// Test each possible metric mapping from the imported DISTANCE_MAPPING constant
|
|
703
|
-
const metricsToTest = Object.keys(DISTANCE_MAPPING) as Array<keyof typeof DISTANCE_MAPPING>;
|
|
704
|
-
|
|
705
|
-
for (const mastraMetric of metricsToTest) {
|
|
706
|
-
const couchbaseMetric = DISTANCE_MAPPING[mastraMetric];
|
|
707
|
-
const testIndexName = `${test_indexName}_${mastraMetric}`;
|
|
708
|
-
|
|
709
|
-
// Create index with this metric
|
|
710
|
-
await couchbase_client.createIndex({
|
|
711
|
-
indexName: testIndexName,
|
|
712
|
-
dimension: dimension,
|
|
713
|
-
metric: mastraMetric,
|
|
714
|
-
});
|
|
715
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
716
|
-
// Verify through the Couchbase API
|
|
717
|
-
const indexDef = await scope.searchIndexes().getIndex(testIndexName);
|
|
718
|
-
const similarityParam =
|
|
719
|
-
indexDef.params.mapping?.types?.[`${test_scopeName}.${test_collectionName}`]?.properties?.embedding
|
|
720
|
-
?.fields?.[0]?.similarity;
|
|
721
|
-
expect(similarityParam).toBe(couchbaseMetric);
|
|
722
|
-
|
|
723
|
-
// Verify through our API
|
|
724
|
-
const stats = await couchbase_client.describeIndex({ indexName: testIndexName });
|
|
725
|
-
expect(stats.metric).toBe(mastraMetric);
|
|
726
|
-
|
|
727
|
-
// Clean up
|
|
728
|
-
await couchbase_client.deleteIndex({ indexName: testIndexName });
|
|
729
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
730
|
-
}
|
|
731
|
-
}, 50000);
|
|
732
|
-
});
|
|
733
|
-
});
|