@cosmocoder/mcp-web-docs 2.0.14 → 2.0.16
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/build/index.js +2 -10
- package/build/index.js.map +1 -1
- package/build/index.test.js +17 -3
- package/build/index.test.js.map +1 -1
- package/build/storage/storage.d.ts +1 -1
- package/build/storage/storage.js +40 -48
- package/build/storage/storage.js.map +1 -1
- package/build/storage/storage.test.js +112 -16
- package/build/storage/storage.test.js.map +1 -1
- package/build/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { join } from 'path';
|
|
|
5
5
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
6
6
|
import { DocumentStore } from './storage.js';
|
|
7
7
|
import { createMockEmbeddings } from '../__mocks__/embeddings.js';
|
|
8
|
+
import { logger } from '../util/logger.js';
|
|
8
9
|
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
9
10
|
function deferred() {
|
|
10
11
|
let resolve;
|
|
@@ -1005,14 +1006,13 @@ describe('DocumentStore', () => {
|
|
|
1005
1006
|
it('should clear search cache for deleted document', async () => {
|
|
1006
1007
|
const url = 'https://example.com/cache-test';
|
|
1007
1008
|
await store.addDocument(createTestDocument(url, 'Cache Test'));
|
|
1008
|
-
|
|
1009
|
-
|
|
1009
|
+
const cachedResults = await store.searchByText('cache test');
|
|
1010
|
+
expect(cachedResults.some((result) => result.url === url)).toBe(true);
|
|
1010
1011
|
// Delete document
|
|
1011
1012
|
await store.deleteDocument(url);
|
|
1012
1013
|
// Search again - should not find the document
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
expect(hasDeletedDoc).toBe(false);
|
|
1014
|
+
const refreshedResults = await store.searchByText('cache test');
|
|
1015
|
+
expect(refreshedResults.some((result) => result.url === url)).toBe(false);
|
|
1016
1016
|
});
|
|
1017
1017
|
});
|
|
1018
1018
|
describe('searchDocuments (vector search)', () => {
|
|
@@ -1067,6 +1067,25 @@ describe('DocumentStore', () => {
|
|
|
1067
1067
|
expect(result.metadata.type).toBe('api');
|
|
1068
1068
|
});
|
|
1069
1069
|
});
|
|
1070
|
+
it('should prefilter pure vector search to exact URLs before applying the limit', async () => {
|
|
1071
|
+
const queryVector = await mockEmbeddings.embed('exact scope query');
|
|
1072
|
+
const globalUrl = 'https://example.com/global-best';
|
|
1073
|
+
const scopedUrl = 'https://example.com/scoped-lower';
|
|
1074
|
+
const globalDoc = createTestDocument(globalUrl, 'Global Best');
|
|
1075
|
+
const scopedDoc = createTestDocument(scopedUrl, 'Scoped Lower');
|
|
1076
|
+
globalDoc.chunks[0].vector = queryVector;
|
|
1077
|
+
scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
|
|
1078
|
+
await store.addDocument(globalDoc);
|
|
1079
|
+
await store.addDocument(scopedDoc);
|
|
1080
|
+
const globalResults = await store.searchDocuments(queryVector, { limit: 1 });
|
|
1081
|
+
const scopedResults = await store.searchDocuments(queryVector, { limit: 1, filterUrls: [scopedUrl] });
|
|
1082
|
+
expect(globalResults[0].url).toBe(globalUrl);
|
|
1083
|
+
expect(scopedResults.map((result) => result.url)).toEqual([scopedUrl]);
|
|
1084
|
+
});
|
|
1085
|
+
it('should return no vector results for an explicit empty URL scope', async () => {
|
|
1086
|
+
const queryVector = await mockEmbeddings.embed('guide');
|
|
1087
|
+
await expect(store.searchDocuments(queryVector, { filterUrls: [] })).resolves.toEqual([]);
|
|
1088
|
+
});
|
|
1070
1089
|
it('should return empty array for empty query vector without text query', async () => {
|
|
1071
1090
|
const results = await store.searchDocuments([], { limit: 5 });
|
|
1072
1091
|
expect(results).toEqual([]);
|
|
@@ -1084,10 +1103,12 @@ describe('DocumentStore', () => {
|
|
|
1084
1103
|
});
|
|
1085
1104
|
it('should use caching for repeated queries', async () => {
|
|
1086
1105
|
const query = 'unique cache test query';
|
|
1106
|
+
const embedSpy = vi.spyOn(mockEmbeddings, 'embed');
|
|
1087
1107
|
const results1 = await store.searchByText(query);
|
|
1088
1108
|
const results2 = await store.searchByText(query);
|
|
1089
1109
|
// Results should be identical (from cache)
|
|
1090
1110
|
expect(results1).toEqual(results2);
|
|
1111
|
+
expect(embedSpy).toHaveBeenCalledTimes(1);
|
|
1091
1112
|
});
|
|
1092
1113
|
it('should respect limit option', async () => {
|
|
1093
1114
|
const results = await store.searchByText('guide', { limit: 1 });
|
|
@@ -1101,9 +1122,60 @@ describe('DocumentStore', () => {
|
|
|
1101
1122
|
expect(result.url.startsWith('https://example.com/react')).toBe(true);
|
|
1102
1123
|
});
|
|
1103
1124
|
});
|
|
1104
|
-
it('should
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1125
|
+
it('should scope both hybrid search legs before ranking', async () => {
|
|
1126
|
+
const query = 'adversarial hybrid scope';
|
|
1127
|
+
const queryVector = await mockEmbeddings.embed(query);
|
|
1128
|
+
const globalUrl = 'https://example.com/hybrid-global-best';
|
|
1129
|
+
const scopedUrl = 'https://example.com/hybrid-scoped-lower';
|
|
1130
|
+
const globalDoc = createDocumentWithContent(globalUrl, 'Global Hybrid', query);
|
|
1131
|
+
const scopedDoc = createDocumentWithContent(scopedUrl, 'Scoped Hybrid', query);
|
|
1132
|
+
globalDoc.chunks[0].vector = queryVector;
|
|
1133
|
+
scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
|
|
1134
|
+
await store.addDocument(globalDoc);
|
|
1135
|
+
await store.addDocument(scopedDoc);
|
|
1136
|
+
expect(store.ftsIndexCreated).toBe(true);
|
|
1137
|
+
const results = await store.searchByText(query, { limit: 2, filterUrls: [scopedUrl] });
|
|
1138
|
+
expect(results.map((result) => result.url)).toEqual([scopedUrl]);
|
|
1139
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
1140
|
+
});
|
|
1141
|
+
it('should preserve exact URL scope in the pure-vector text fallback', async () => {
|
|
1142
|
+
const query = 'fallback scope query';
|
|
1143
|
+
const queryVector = await mockEmbeddings.embed(query);
|
|
1144
|
+
const globalUrl = 'https://example.com/fallback-global-best';
|
|
1145
|
+
const scopedUrl = 'https://example.com/fallback-scoped-lower';
|
|
1146
|
+
const globalDoc = createTestDocument(globalUrl, 'Fallback Global Best');
|
|
1147
|
+
const scopedDoc = createTestDocument(scopedUrl, 'Fallback Scoped Lower');
|
|
1148
|
+
globalDoc.chunks[0].vector = queryVector;
|
|
1149
|
+
scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
|
|
1150
|
+
await store.addDocument(globalDoc);
|
|
1151
|
+
await store.addDocument(scopedDoc);
|
|
1152
|
+
store.ftsIndexCreated = false;
|
|
1153
|
+
const globalResults = await store.searchByText(query, { limit: 1 });
|
|
1154
|
+
const scopedResults = await store.searchByText(query, { limit: 1, filterUrls: [scopedUrl] });
|
|
1155
|
+
expect(globalResults[0].url).toBe(globalUrl);
|
|
1156
|
+
expect(scopedResults.map((result) => result.url)).toEqual([scopedUrl]);
|
|
1157
|
+
});
|
|
1158
|
+
it('should return no text results for an explicit empty URL scope', async () => {
|
|
1159
|
+
await expect(store.searchByText('guide', { filterUrls: [] })).resolves.toEqual([]);
|
|
1160
|
+
});
|
|
1161
|
+
it('should scope both phrase search legs before ranking', async () => {
|
|
1162
|
+
const phrase = 'adversarial phrase scope';
|
|
1163
|
+
const query = `"${phrase}"`;
|
|
1164
|
+
const queryVector = await mockEmbeddings.embed(query);
|
|
1165
|
+
const globalUrl = 'https://example.com/phrase-global-best';
|
|
1166
|
+
const scopedUrl = 'https://example.com/phrase-scoped-lower';
|
|
1167
|
+
const globalDoc = createDocumentWithContent(globalUrl, 'Global Phrase', phrase);
|
|
1168
|
+
const scopedDoc = createDocumentWithContent(scopedUrl, 'Scoped Phrase', phrase);
|
|
1169
|
+
globalDoc.chunks[0].vector = queryVector;
|
|
1170
|
+
scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
|
|
1171
|
+
await store.addDocument(globalDoc);
|
|
1172
|
+
await store.addDocument(scopedDoc);
|
|
1173
|
+
const internals = replacementInternals();
|
|
1174
|
+
internals.ftsIndexCreated = false;
|
|
1175
|
+
await internals.createFTSIndex();
|
|
1176
|
+
const results = await store.searchByText(query, { limit: 2, filterUrls: [scopedUrl] });
|
|
1177
|
+
expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/Phrase-based FTS returned [1-9]/));
|
|
1178
|
+
expect(results.map((result) => result.url)).toEqual([scopedUrl]);
|
|
1107
1179
|
});
|
|
1108
1180
|
it('should handle empty query gracefully', async () => {
|
|
1109
1181
|
// Generate embedding for empty query
|
|
@@ -1336,6 +1408,13 @@ describe('DocumentStore', () => {
|
|
|
1336
1408
|
// Should not throw and should return empty (no match)
|
|
1337
1409
|
expect(Array.isArray(results)).toBe(true);
|
|
1338
1410
|
});
|
|
1411
|
+
it('should safely filter by exact URLs with special characters', async () => {
|
|
1412
|
+
const url = "https://example.com/team's-guide";
|
|
1413
|
+
await store.addDocument(createTestDocument(url, 'Special URL Guide'));
|
|
1414
|
+
const results = await store.searchByText('guide', { filterUrls: [url] });
|
|
1415
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1416
|
+
expect(results.every((result) => result.url === url)).toBe(true);
|
|
1417
|
+
});
|
|
1339
1418
|
});
|
|
1340
1419
|
describe('document tags', () => {
|
|
1341
1420
|
it('should set and retrieve tags for a document', async () => {
|
|
@@ -1499,6 +1578,15 @@ describe('DocumentStore', () => {
|
|
|
1499
1578
|
expect(result.url.startsWith('https://example.com/react')).toBe(true);
|
|
1500
1579
|
});
|
|
1501
1580
|
});
|
|
1581
|
+
it('should intersect exact URL, prefix URL, and tag filters', async () => {
|
|
1582
|
+
const results = await store.searchByText('guide', {
|
|
1583
|
+
filterByTags: ['frontend'],
|
|
1584
|
+
filterUrl: 'https://example.com/react',
|
|
1585
|
+
filterUrls: ['https://example.com/react-hooks', 'https://example.com/express-api'],
|
|
1586
|
+
});
|
|
1587
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1588
|
+
expect(results.every((result) => result.url === 'https://example.com/react-hooks')).toBe(true);
|
|
1589
|
+
});
|
|
1502
1590
|
});
|
|
1503
1591
|
describe('document version', () => {
|
|
1504
1592
|
it('should store and retrieve version for a document', async () => {
|
|
@@ -1605,15 +1693,23 @@ describe('DocumentStore', () => {
|
|
|
1605
1693
|
expect(typeof result.compacted).toBe('boolean');
|
|
1606
1694
|
expect(typeof result.cleanedUp).toBe('boolean');
|
|
1607
1695
|
});
|
|
1608
|
-
it('should
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1696
|
+
it('should not cache a search that started before optimization', async () => {
|
|
1697
|
+
const query = 'in-flight optimization cache';
|
|
1698
|
+
const embed = mockEmbeddings.embed.bind(mockEmbeddings);
|
|
1699
|
+
const searchStarted = Promise.withResolvers();
|
|
1700
|
+
const releaseSearch = Promise.withResolvers();
|
|
1701
|
+
const embedSpy = vi.spyOn(mockEmbeddings, 'embed').mockImplementationOnce(async (text) => {
|
|
1702
|
+
searchStarted.resolve();
|
|
1703
|
+
await releaseSearch.promise;
|
|
1704
|
+
return embed(text);
|
|
1705
|
+
});
|
|
1706
|
+
const inFlightSearch = store.searchByText(query);
|
|
1707
|
+
await searchStarted.promise;
|
|
1613
1708
|
await store.optimize();
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1709
|
+
releaseSearch.resolve();
|
|
1710
|
+
await inFlightSearch;
|
|
1711
|
+
await store.searchByText(query);
|
|
1712
|
+
expect(embedSpy).toHaveBeenCalledTimes(2);
|
|
1617
1713
|
});
|
|
1618
1714
|
it('should preserve data after optimization', async () => {
|
|
1619
1715
|
// Add documents
|