knowledgegraph-mcp
Version:
MCP server for enabling persistent knowledge storage for Claude through a knowledge graph with multiple storage backends
383 lines • 15.1 kB
JavaScript
import Fuse from 'fuse.js';
import { BaseSearchStrategy } from './base-strategy.js';
import { getValidatedSearchLimits } from '../config.js';
/**
* SQLite search strategy - uses client-side fuzzy search only
* SQLite doesn't have built-in fuzzy search capabilities like PostgreSQL,
* so we always use Fuse.js for fuzzy searching
*/
export class SQLiteFuzzyStrategy extends BaseSearchStrategy {
db;
project;
searchLimits = getValidatedSearchLimits();
constructor(config, db, project) {
super(config);
this.db = db;
this.project = project;
}
canUseDatabase() {
// SQLite doesn't support advanced fuzzy search at database level
// Always use client-side search with Fuse.js
return false;
}
async searchDatabase(query, threshold, project) {
// SQLite doesn't support database-level fuzzy search
// This method should not be called since canUseDatabase() returns false
throw new Error('SQLite does not support database-level fuzzy search. Use client-side search instead.');
}
searchClientSide(entities, query) {
// Handle multiple queries
if (Array.isArray(query)) {
return this.searchMultipleClientSide(entities, query);
}
// Single query - use existing logic
return this.searchSingleClientSide(entities, query);
}
searchSingleClientSide(entities, query) {
// Use chunking for large entity sets to improve performance
if (entities.length > this.searchLimits.clientSideChunkSize) {
console.log(`SQLite: Using chunked search for ${entities.length} entities (chunk size: ${this.searchLimits.clientSideChunkSize})`);
return this.searchClientSideChunked(entities, query, this.searchLimits.clientSideChunkSize);
}
const fuseOptions = {
threshold: this.config.threshold,
distance: 100,
includeScore: true,
keys: ['name', 'entityType', 'observations', 'tags'],
...this.config.fuseOptions
};
const fuse = new Fuse(entities, fuseOptions);
const results = fuse.search(query);
return results.map(result => result.item);
}
/**
* Get all entities for a project from SQLite database
* This is used to load entities for client-side search
* Respects maxClientSideEntities limit to prevent memory issues
*/
async getAllEntities(project) {
const searchProject = project || this.project;
try {
const stmt = this.db.prepare(`
SELECT name, entity_type, observations, tags
FROM entities
WHERE project = ?
ORDER BY updated_at DESC, name
LIMIT ?
`);
const rows = stmt.all(searchProject, this.searchLimits.maxClientSideEntities);
// Log warning if we hit the limit
if (rows.length === this.searchLimits.maxClientSideEntities) {
console.warn(`SQLite getAllEntities: Hit maxClientSideEntities limit of ${this.searchLimits.maxClientSideEntities}. Consider increasing KNOWLEDGEGRAPH_SEARCH_MAX_CLIENT_ENTITIES or using database-level search.`);
}
return rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
}
catch (error) {
console.error('Failed to load entities from SQLite:', error);
throw error; // Throw to be consistent with PostgreSQL behavior
}
}
/**
* Perform exact search at database level for better performance
* This can be used as an optimization for exact searches
*/
async searchExact(query, project) {
// Handle multiple queries with optimized SQL
if (Array.isArray(query)) {
return this.searchExactMultiple(query, project);
}
// Single query - use existing logic
return this.searchExactSingle(query, project);
}
/**
* Optimized multiple exact search using single SQL query with OR conditions
* This provides better performance than sequential searches
*/
async searchExactMultiple(queries, project) {
// Handle empty queries array
if (queries.length === 0) {
return [];
}
const searchProject = project || this.project;
try {
// Build OR conditions for multiple terms
const conditions = queries.map(() => `(LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ?)`).join(' OR ');
// Prepare statement with dynamic parameters
const stmt = this.db.prepare(`
SELECT DISTINCT name, entity_type, observations, tags
FROM entities
WHERE project = ? AND (${conditions})
ORDER BY updated_at DESC, name
LIMIT ?
`);
// Create parameters array: [project, pattern1, pattern1, pattern1, pattern1, pattern2, ..., limit]
const params = [searchProject];
for (const query of queries) {
const pattern = `%${query.toLowerCase()}%`;
params.push(pattern, pattern, pattern, pattern);
}
params.push(this.searchLimits.maxResults);
const rows = stmt.all(...params);
return rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
}
catch (error) {
console.error('Failed to perform multiple exact search in SQLite:', error);
throw error; // Throw to be consistent with PostgreSQL behavior
}
}
/**
* Single exact search implementation
*/
async searchExactSingle(query, project) {
const searchProject = project || this.project;
const lowerQuery = query.toLowerCase();
try {
const stmt = this.db.prepare(`
SELECT name, entity_type, observations, tags
FROM entities
WHERE project = ?
AND (
LOWER(name) LIKE ?
OR LOWER(entity_type) LIKE ?
OR LOWER(observations) LIKE ?
OR LOWER(tags) LIKE ?
)
ORDER BY updated_at DESC, name
LIMIT ?
`);
const searchPattern = `%${lowerQuery}%`;
const rows = stmt.all(searchProject, searchPattern, searchPattern, searchPattern, searchPattern, this.searchLimits.maxResults);
return rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
}
catch (error) {
console.error('Failed to perform exact search in SQLite:', error);
throw error; // Throw to be consistent with PostgreSQL behavior
}
}
/**
* Get all entities with pagination support
*/
async getAllEntitiesPaginated(pagination, project) {
const searchProject = project || this.project;
const page = pagination.page || 0;
const pageSize = pagination.pageSize || 100;
const offset = page * pageSize;
try {
// Get total count
const countStmt = this.db.prepare(`
SELECT COUNT(*) as total_count
FROM entities
WHERE project = ?
`);
const countResult = countStmt.get(searchProject);
const totalCount = countResult.total_count;
// Get paginated data
const dataStmt = this.db.prepare(`
SELECT name, entity_type, observations, tags
FROM entities
WHERE project = ?
ORDER BY updated_at DESC, name
LIMIT ? OFFSET ?
`);
const rows = dataStmt.all(searchProject, pageSize, offset);
const entities = rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: entities,
pagination: {
currentPage: page,
pageSize: pageSize,
totalCount: totalCount,
totalPages: totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0
}
};
}
catch (error) {
console.error('Failed to load paginated entities from SQLite:', error);
throw error;
}
}
/**
* Perform exact search with pagination support
*/
async searchExactPaginated(query, pagination, project) {
// Handle multiple queries
if (Array.isArray(query)) {
return this.searchExactMultiplePaginated(query, pagination, project);
}
// Single query
return this.searchExactSinglePaginated(query, pagination, project);
}
/**
* Single exact search with pagination
*/
async searchExactSinglePaginated(query, pagination, project) {
const searchProject = project || this.project;
const lowerQuery = query.toLowerCase();
const page = pagination.page || 0;
const pageSize = pagination.pageSize || 100;
const offset = page * pageSize;
try {
const searchPattern = `%${lowerQuery}%`;
// Get total count
const countStmt = this.db.prepare(`
SELECT COUNT(*) as total_count
FROM entities
WHERE project = ?
AND (
LOWER(name) LIKE ?
OR LOWER(entity_type) LIKE ?
OR LOWER(observations) LIKE ?
OR LOWER(tags) LIKE ?
)
`);
const countResult = countStmt.get(searchProject, searchPattern, searchPattern, searchPattern, searchPattern);
const totalCount = countResult.total_count;
// Get paginated data
const dataStmt = this.db.prepare(`
SELECT name, entity_type, observations, tags
FROM entities
WHERE project = ?
AND (
LOWER(name) LIKE ?
OR LOWER(entity_type) LIKE ?
OR LOWER(observations) LIKE ?
OR LOWER(tags) LIKE ?
)
ORDER BY updated_at DESC, name
LIMIT ? OFFSET ?
`);
const rows = dataStmt.all(searchProject, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset);
const entities = rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: entities,
pagination: {
currentPage: page,
pageSize: pageSize,
totalCount: totalCount,
totalPages: totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0
}
};
}
catch (error) {
console.error('Failed to perform paginated exact search in SQLite:', error);
throw error;
}
}
/**
* Multiple exact search with pagination
*/
async searchExactMultiplePaginated(queries, pagination, project) {
if (queries.length === 0) {
return {
data: [],
pagination: {
currentPage: pagination.page || 0,
pageSize: pagination.pageSize || 100,
totalCount: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false
}
};
}
const searchProject = project || this.project;
const page = pagination.page || 0;
const pageSize = pagination.pageSize || 100;
const offset = page * pageSize;
try {
// Build OR conditions for multiple terms
const conditions = queries.map(() => `(LOWER(name) LIKE ? OR LOWER(entity_type) LIKE ? OR LOWER(observations) LIKE ? OR LOWER(tags) LIKE ?)`).join(' OR ');
// Create parameters array for patterns
const params = [];
for (const query of queries) {
const pattern = `%${query.toLowerCase()}%`;
params.push(pattern, pattern, pattern, pattern);
}
// Get total count
const countStmt = this.db.prepare(`
SELECT COUNT(DISTINCT name) as total_count
FROM entities
WHERE project = ? AND (${conditions})
`);
const countResult = countStmt.get(searchProject, ...params);
const totalCount = countResult.total_count;
// Get paginated data
const dataStmt = this.db.prepare(`
SELECT DISTINCT name, entity_type, observations, tags
FROM entities
WHERE project = ? AND (${conditions})
ORDER BY updated_at DESC, name
LIMIT ? OFFSET ?
`);
const rows = dataStmt.all(searchProject, ...params, pageSize, offset);
const entities = rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: this.safeJsonParse(row.observations, []),
tags: this.safeJsonParse(row.tags, [])
}));
const totalPages = Math.ceil(totalCount / pageSize);
return {
data: entities,
pagination: {
currentPage: page,
pageSize: pageSize,
totalCount: totalCount,
totalPages: totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0
}
};
}
catch (error) {
console.error('Failed to perform paginated multiple exact search in SQLite:', error);
throw error;
}
}
/**
* Safely parse JSON with fallback to default value
*/
safeJsonParse(jsonString, defaultValue) {
if (!jsonString) {
return defaultValue;
}
try {
return JSON.parse(jsonString);
}
catch (error) {
return defaultValue;
}
}
}
//# sourceMappingURL=sqlite-strategy.js.map