mirror of
https://github.com/zadam/trilium.git
synced 2025-12-06 07:24:25 +01:00
Compare commits
8 Commits
41f6fedc61
...
09df73e125
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09df73e125 | ||
|
|
f21aa321f6 | ||
|
|
7be8b6c71e | ||
|
|
bb8e5ebd4a | ||
|
|
6b8b71f7d1 | ||
|
|
191a18d7f6 | ||
|
|
574a3441ee | ||
|
|
9940ee3bee |
Binary file not shown.
@ -204,9 +204,6 @@ ON revisions (noteId, utcDateCreated DESC);
|
||||
CREATE INDEX IDX_entity_changes_sync
|
||||
ON entity_changes (isSynced, utcDateChanged);
|
||||
|
||||
CREATE INDEX IDX_entity_changes_component
|
||||
ON entity_changes (componentId, utcDateChanged DESC);
|
||||
|
||||
-- RECENT_NOTES TABLE INDEXES
|
||||
CREATE INDEX IDX_recent_notes_date
|
||||
ON recent_notes (utcDateCreated DESC);
|
||||
|
||||
@ -17,29 +17,12 @@ import log from "../services/log.js";
|
||||
export default function addFTS5SearchAndPerformanceIndexes() {
|
||||
log.info("Starting FTS5 and performance optimization migration...");
|
||||
|
||||
// Verify SQLite version supports trigram tokenizer (requires 3.34.0+)
|
||||
const sqliteVersion = sql.getValue<string>(`SELECT sqlite_version()`);
|
||||
const [major, minor, patch] = sqliteVersion.split('.').map(Number);
|
||||
const versionNumber = major * 10000 + minor * 100 + (patch || 0);
|
||||
const requiredVersion = 3 * 10000 + 34 * 100 + 0; // 3.34.0
|
||||
|
||||
if (versionNumber < requiredVersion) {
|
||||
log.error(`SQLite version ${sqliteVersion} does not support trigram tokenizer (requires 3.34.0+)`);
|
||||
log.info("Skipping FTS5 trigram migration - will use fallback search implementation");
|
||||
return; // Skip FTS5 setup, rely on fallback search
|
||||
}
|
||||
|
||||
log.info(`SQLite version ${sqliteVersion} confirmed - trigram tokenizer available`);
|
||||
|
||||
// Part 1: FTS5 Setup
|
||||
log.info("Creating FTS5 virtual table for full-text search...");
|
||||
|
||||
// Create FTS5 virtual table
|
||||
// We store noteId, title, and content for searching
|
||||
sql.executeScript(`
|
||||
-- Drop existing FTS table if it exists (for re-running migration in dev)
|
||||
DROP TABLE IF EXISTS notes_fts;
|
||||
|
||||
-- Create FTS5 virtual table with trigram tokenizer
|
||||
-- Trigram tokenizer provides language-agnostic substring matching:
|
||||
-- 1. Fast substring matching (50-100x speedup for LIKE queries without wildcards)
|
||||
@ -503,15 +486,6 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
||||
`);
|
||||
indexesCreated.push("IDX_entity_changes_sync");
|
||||
|
||||
// Index for component-based queries
|
||||
log.info("Creating index for component-based entity change queries...");
|
||||
sql.executeScript(`
|
||||
DROP INDEX IF EXISTS IDX_entity_changes_component;
|
||||
CREATE INDEX IF NOT EXISTS IDX_entity_changes_component
|
||||
ON entity_changes (componentId, utcDateChanged DESC);
|
||||
`);
|
||||
indexesCreated.push("IDX_entity_changes_component");
|
||||
|
||||
// ========================================
|
||||
// RECENT_NOTES TABLE INDEXES
|
||||
// ========================================
|
||||
@ -648,5 +622,22 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
||||
log.info("Attributes FTS5 setup completed successfully");
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Part 4: Cleanup legacy custom search tables
|
||||
// ========================================
|
||||
// Remove tables from previous custom SQLite search implementation
|
||||
// that has been replaced by FTS5
|
||||
|
||||
log.info("Cleaning up legacy custom search tables...");
|
||||
|
||||
sql.executeScript(`DROP TABLE IF EXISTS note_search_content`);
|
||||
sql.executeScript(`DROP TABLE IF EXISTS note_tokens`);
|
||||
|
||||
// Clean up any entity changes for these tables
|
||||
sql.execute(`
|
||||
DELETE FROM entity_changes
|
||||
WHERE entityName IN ('note_search_content', 'note_tokens')
|
||||
`);
|
||||
|
||||
log.info("FTS5 and performance optimization migration completed successfully");
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Migration to clean up custom SQLite search implementation
|
||||
*
|
||||
* This migration removes tables and triggers created by migration 0235
|
||||
* which implemented a custom SQLite-based search system. That system
|
||||
* has been replaced by FTS5 with trigram tokenizer (migration 0234),
|
||||
* making these custom tables redundant.
|
||||
*
|
||||
* Tables removed:
|
||||
* - note_search_content: Stored normalized note content for custom search
|
||||
* - note_tokens: Stored tokenized words for custom token-based search
|
||||
*
|
||||
* This migration is safe to run on databases that:
|
||||
* 1. Never ran migration 0235 (tables don't exist)
|
||||
* 2. Already ran migration 0235 (tables will be dropped)
|
||||
*/
|
||||
|
||||
import sql from "../services/sql.js";
|
||||
import log from "../services/log.js";
|
||||
|
||||
export default function cleanupSqliteSearch() {
|
||||
log.info("Starting SQLite custom search cleanup migration...");
|
||||
|
||||
try {
|
||||
sql.transactional(() => {
|
||||
// Drop custom search tables if they exist
|
||||
log.info("Dropping note_search_content table...");
|
||||
sql.executeScript(`DROP TABLE IF EXISTS note_search_content`);
|
||||
|
||||
log.info("Dropping note_tokens table...");
|
||||
sql.executeScript(`DROP TABLE IF EXISTS note_tokens`);
|
||||
|
||||
// Clean up any entity changes for these tables
|
||||
// This prevents sync issues and cleans up change tracking
|
||||
log.info("Cleaning up entity changes for removed tables...");
|
||||
sql.execute(`
|
||||
DELETE FROM entity_changes
|
||||
WHERE entityName IN ('note_search_content', 'note_tokens')
|
||||
`);
|
||||
|
||||
log.info("SQLite custom search cleanup completed successfully");
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Error during SQLite search cleanup: ${error}`);
|
||||
throw new Error(`Failed to clean up SQLite search tables: ${error}`);
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,6 @@
|
||||
|
||||
// Migrations should be kept in descending order, so the latest migration is first.
|
||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||
// Clean up custom SQLite search tables (replaced by FTS5 trigram)
|
||||
{
|
||||
version: 236,
|
||||
module: async () => import("./0236__cleanup_sqlite_search.js")
|
||||
},
|
||||
// Add FTS5 full-text search support and strategic performance indexes
|
||||
{
|
||||
version: 234,
|
||||
|
||||
@ -10,7 +10,7 @@ import cls from "../../services/cls.js";
|
||||
import attributeFormatter from "../../services/attribute_formatter.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type SearchResult from "../../services/search/search_result.js";
|
||||
import ftsSearchService from "../../services/search/fts_search.js";
|
||||
import { ftsSearchService } from "../../services/search/fts/index.js";
|
||||
import log from "../../services/log.js";
|
||||
import hoistedNoteService from "../../services/hoisted_note.js";
|
||||
import beccaService from "../../becca/becca_service.js";
|
||||
|
||||
@ -11,7 +11,7 @@ import auth from "../services/auth.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 2500;
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||
export const router = express.Router();
|
||||
|
||||
// TODO: Deduplicate with etapi_utils.ts afterwards.
|
||||
@ -183,7 +183,7 @@ export function createUploadMiddleware(): RequestHandler {
|
||||
|
||||
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
||||
multerOptions.limits = {
|
||||
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024 * 1024
|
||||
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
const APP_DB_VERSION = 236;
|
||||
const APP_DB_VERSION = 234;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
getRegex,
|
||||
FUZZY_SEARCH_CONFIG
|
||||
} from "../utils/text_utils.js";
|
||||
import ftsSearchService, { FTSError, FTSQueryError } from "../fts_search.js";
|
||||
import { ftsSearchService, FTSError, FTSQueryError } from "../fts/index.js";
|
||||
|
||||
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
|
||||
|
||||
|
||||
30
apps/server/src/services/search/fts/errors.ts
Normal file
30
apps/server/src/services/search/fts/errors.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* FTS5 Error Classes
|
||||
*
|
||||
* Custom error types for FTS5 operations to enable proper error handling
|
||||
* and recovery strategies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base error class for FTS operations
|
||||
*/
|
||||
export class FTSError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly recoverable: boolean = true
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FTSError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an FTS query is malformed or invalid
|
||||
*/
|
||||
export class FTSQueryError extends FTSError {
|
||||
constructor(message: string, public readonly query?: string) {
|
||||
super(message, 'FTS_QUERY_ERROR', true);
|
||||
this.name = 'FTSQueryError';
|
||||
}
|
||||
}
|
||||
90
apps/server/src/services/search/fts/index.ts
Normal file
90
apps/server/src/services/search/fts/index.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FTS5 Search Module
|
||||
*
|
||||
* Barrel export for all FTS5 functionality.
|
||||
* This module provides full-text search using SQLite's FTS5 extension.
|
||||
*/
|
||||
|
||||
// Error classes
|
||||
export { FTSError, FTSQueryError } from "./errors.js";
|
||||
|
||||
// Types and configuration
|
||||
export {
|
||||
FTS_CONFIG,
|
||||
type FTSSearchResult,
|
||||
type FTSSearchOptions,
|
||||
type FTSErrorInfo,
|
||||
type FTSIndexStats
|
||||
} from "./types.js";
|
||||
|
||||
// Query building utilities
|
||||
export {
|
||||
convertToFTS5Query,
|
||||
sanitizeFTS5Token,
|
||||
escapeLikeWildcards,
|
||||
containsExactPhrase,
|
||||
generateSnippet
|
||||
} from "./query_builder.js";
|
||||
|
||||
// Index management
|
||||
export {
|
||||
assertFTS5Available,
|
||||
checkFTS5Availability,
|
||||
updateNoteIndex,
|
||||
removeNoteFromIndex,
|
||||
syncMissingNotes,
|
||||
rebuildIndex,
|
||||
getIndexStats,
|
||||
filterNonProtectedNoteIds
|
||||
} from "./index_manager.js";
|
||||
|
||||
// Search operations
|
||||
export {
|
||||
searchWithLike,
|
||||
searchSync,
|
||||
searchAttributesSync,
|
||||
searchProtectedNotesSync
|
||||
} from "./search_service.js";
|
||||
|
||||
// Legacy class-based API for backward compatibility
|
||||
import {
|
||||
assertFTS5Available,
|
||||
checkFTS5Availability,
|
||||
updateNoteIndex,
|
||||
removeNoteFromIndex,
|
||||
syncMissingNotes,
|
||||
rebuildIndex,
|
||||
getIndexStats
|
||||
} from "./index_manager.js";
|
||||
import {
|
||||
searchWithLike,
|
||||
searchSync,
|
||||
searchAttributesSync,
|
||||
searchProtectedNotesSync
|
||||
} from "./search_service.js";
|
||||
import { convertToFTS5Query } from "./query_builder.js";
|
||||
|
||||
/**
|
||||
* FTS Search Service class
|
||||
*
|
||||
* Provides a class-based API for backward compatibility.
|
||||
* New code should prefer the individual exported functions.
|
||||
*/
|
||||
class FTSSearchService {
|
||||
assertFTS5Available = assertFTS5Available;
|
||||
checkFTS5Availability = checkFTS5Availability;
|
||||
convertToFTS5Query = convertToFTS5Query;
|
||||
searchWithLike = searchWithLike;
|
||||
searchSync = searchSync;
|
||||
searchAttributesSync = searchAttributesSync;
|
||||
searchProtectedNotesSync = searchProtectedNotesSync;
|
||||
updateNoteIndex = updateNoteIndex;
|
||||
removeNoteFromIndex = removeNoteFromIndex;
|
||||
syncMissingNotes = syncMissingNotes;
|
||||
rebuildIndex = rebuildIndex;
|
||||
getIndexStats = getIndexStats;
|
||||
}
|
||||
|
||||
// Export singleton instance for backward compatibility
|
||||
export const ftsSearchService = new FTSSearchService();
|
||||
export default ftsSearchService;
|
||||
262
apps/server/src/services/search/fts/index_manager.ts
Normal file
262
apps/server/src/services/search/fts/index_manager.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* FTS5 Index Manager
|
||||
*
|
||||
* Handles FTS5 index CRUD operations including:
|
||||
* - Index availability verification
|
||||
* - Note indexing and removal
|
||||
* - Index synchronization and rebuilding
|
||||
* - Index statistics
|
||||
*/
|
||||
|
||||
import sql from "../../sql.js";
|
||||
import log from "../../log.js";
|
||||
import type { FTSIndexStats } from "./types.js";
|
||||
|
||||
/**
|
||||
* Asserts that FTS5 is available. Should be called at application startup.
|
||||
* Throws an error if FTS5 tables are not found.
|
||||
*/
|
||||
export function assertFTS5Available(): void {
|
||||
const result = sql.getValue<number>(`
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = 'notes_fts'
|
||||
`);
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error("CRITICAL: FTS5 table 'notes_fts' not found. Run database migration.");
|
||||
}
|
||||
|
||||
log.info("FTS5 tables verified - full-text search is available");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if FTS5 is available.
|
||||
* @returns Always returns true - FTS5 is required and validated at startup.
|
||||
* @deprecated This method is kept for API compatibility. FTS5 is now required.
|
||||
*/
|
||||
export function checkFTS5Availability(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the FTS index for a specific note (synchronous)
|
||||
*
|
||||
* @param noteId - The note ID to update
|
||||
* @param title - The note title
|
||||
* @param content - The note content
|
||||
*/
|
||||
export function updateNoteIndex(noteId: string, title: string, content: string): void {
|
||||
try {
|
||||
sql.transactional(() => {
|
||||
// Delete existing entry
|
||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
||||
|
||||
// Insert new entry
|
||||
sql.execute(`
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
VALUES (?, ?, ?)
|
||||
`, [noteId, title, content]);
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Failed to update FTS index for note ${noteId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a note from the FTS index (synchronous)
|
||||
*
|
||||
* @param noteId - The note ID to remove
|
||||
*/
|
||||
export function removeNoteFromIndex(noteId: string): void {
|
||||
try {
|
||||
sql.execute(`DELETE FROM notes_fts WHERE noteId = ?`, [noteId]);
|
||||
} catch (error) {
|
||||
log.error(`Failed to remove note ${noteId} from FTS index: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs missing notes to the FTS index (synchronous)
|
||||
* This is useful after bulk operations like imports where triggers might not fire
|
||||
*
|
||||
* @param noteIds - Optional array of specific note IDs to sync. If not provided, syncs all missing notes.
|
||||
* @returns The number of notes that were synced
|
||||
*/
|
||||
export function syncMissingNotes(noteIds?: string[]): number {
|
||||
try {
|
||||
let syncedCount = 0;
|
||||
|
||||
sql.transactional(() => {
|
||||
let query: string;
|
||||
let params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.length > 0) {
|
||||
// Sync specific notes that are missing from FTS
|
||||
const placeholders = noteIds.map(() => '?').join(',');
|
||||
query = `
|
||||
WITH missing_notes AS (
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.noteId IN (${placeholders})
|
||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
AND b.content IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
||||
)
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT noteId, title, content FROM missing_notes
|
||||
`;
|
||||
params = noteIds;
|
||||
} else {
|
||||
// Sync all missing notes
|
||||
query = `
|
||||
WITH missing_notes AS (
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
AND b.content IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM notes_fts WHERE noteId = n.noteId)
|
||||
)
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT noteId, title, content FROM missing_notes
|
||||
`;
|
||||
}
|
||||
|
||||
const result = sql.execute(query, params);
|
||||
syncedCount = result.changes;
|
||||
|
||||
if (syncedCount > 0) {
|
||||
log.info(`Synced ${syncedCount} missing notes to FTS index`);
|
||||
// Optimize if we synced a significant number of notes
|
||||
if (syncedCount > 100) {
|
||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
log.error(`Failed to sync missing notes to FTS index: ${error}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the entire FTS index (synchronous)
|
||||
* This is useful for maintenance or after bulk operations
|
||||
*/
|
||||
export function rebuildIndex(): void {
|
||||
log.info("Rebuilding FTS5 index...");
|
||||
|
||||
try {
|
||||
sql.transactional(() => {
|
||||
// Clear existing index
|
||||
sql.execute(`DELETE FROM notes_fts`);
|
||||
|
||||
// Rebuild from notes
|
||||
sql.execute(`
|
||||
INSERT INTO notes_fts (noteId, title, content)
|
||||
SELECT
|
||||
n.noteId,
|
||||
n.title,
|
||||
b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
`);
|
||||
|
||||
// Optimize the FTS table
|
||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||
});
|
||||
|
||||
log.info("FTS5 index rebuild completed");
|
||||
} catch (error) {
|
||||
log.error(`Failed to rebuild FTS index: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets statistics about the FTS index (synchronous)
|
||||
* Includes fallback when dbstat is not available
|
||||
*/
|
||||
export function getIndexStats(): FTSIndexStats {
|
||||
const totalDocuments = sql.getValue<number>(`
|
||||
SELECT COUNT(*) FROM notes_fts
|
||||
`) || 0;
|
||||
|
||||
let indexSize = 0;
|
||||
let dbstatAvailable = false;
|
||||
|
||||
try {
|
||||
// Try to get index size from dbstat
|
||||
// dbstat is a virtual table that may not be available in all SQLite builds
|
||||
indexSize = sql.getValue<number>(`
|
||||
SELECT SUM(pgsize)
|
||||
FROM dbstat
|
||||
WHERE name LIKE 'notes_fts%'
|
||||
`) || 0;
|
||||
dbstatAvailable = true;
|
||||
} catch (error: any) {
|
||||
// dbstat not available, use fallback
|
||||
if (error.message?.includes('no such table: dbstat')) {
|
||||
log.info("dbstat virtual table not available, using fallback for index size estimation");
|
||||
|
||||
// Fallback: Estimate based on number of documents and average content size
|
||||
try {
|
||||
const avgContentSize = sql.getValue<number>(`
|
||||
SELECT AVG(LENGTH(content) + LENGTH(title))
|
||||
FROM notes_fts
|
||||
LIMIT 1000
|
||||
`) || 0;
|
||||
|
||||
// Rough estimate: avg size * document count * overhead factor
|
||||
indexSize = Math.round(avgContentSize * totalDocuments * 1.5);
|
||||
} catch (fallbackError) {
|
||||
log.info(`Could not estimate index size: ${fallbackError}`);
|
||||
indexSize = 0;
|
||||
}
|
||||
} else {
|
||||
log.error(`Error accessing dbstat: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalDocuments,
|
||||
indexSize,
|
||||
isOptimized: true, // FTS5 manages optimization internally
|
||||
dbstatAvailable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out protected note IDs from the given set
|
||||
*/
|
||||
export function filterNonProtectedNoteIds(noteIds: Set<string>): string[] {
|
||||
const noteIdList = Array.from(noteIds);
|
||||
const placeholders = noteIdList.map(() => '?').join(',');
|
||||
|
||||
const nonProtectedNotes = sql.getColumn<string>(`
|
||||
SELECT noteId
|
||||
FROM notes
|
||||
WHERE noteId IN (${placeholders})
|
||||
AND isProtected = 0
|
||||
`, noteIdList);
|
||||
|
||||
return nonProtectedNotes;
|
||||
}
|
||||
154
apps/server/src/services/search/fts/query_builder.ts
Normal file
154
apps/server/src/services/search/fts/query_builder.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* FTS5 Query Builder
|
||||
*
|
||||
* Utilities for converting Trilium search syntax to FTS5 MATCH syntax,
|
||||
* sanitizing tokens, and handling text matching operations.
|
||||
*/
|
||||
|
||||
import striptags from "striptags";
|
||||
import log from "../../log.js";
|
||||
import { FTSQueryError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Converts Trilium search syntax to FTS5 MATCH syntax
|
||||
*
|
||||
* @param tokens - Array of search tokens
|
||||
* @param operator - Trilium search operator
|
||||
* @returns FTS5 MATCH query string
|
||||
*/
|
||||
export function convertToFTS5Query(tokens: string[], operator: string): string {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
throw new Error("No search tokens provided");
|
||||
}
|
||||
|
||||
// Substring operators (*=*, *=, =*) use LIKE queries now, not MATCH
|
||||
if (operator === "*=*" || operator === "*=" || operator === "=*") {
|
||||
throw new Error("Substring operators should use searchWithLike(), not MATCH queries");
|
||||
}
|
||||
|
||||
// Trigram tokenizer requires minimum 3 characters
|
||||
const shortTokens = tokens.filter(token => token.length < 3);
|
||||
if (shortTokens.length > 0) {
|
||||
const shortList = shortTokens.join(', ');
|
||||
log.info(`Tokens shorter than 3 characters detected (${shortList}) - cannot use trigram FTS5`);
|
||||
throw new FTSQueryError(
|
||||
`Trigram tokenizer requires tokens of at least 3 characters. Short tokens: ${shortList}`
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize tokens to prevent FTS5 syntax injection
|
||||
const sanitizedTokens = tokens.map(token => sanitizeFTS5Token(token));
|
||||
|
||||
// Only handle operators that work with MATCH
|
||||
switch (operator) {
|
||||
case "=": // Exact phrase match
|
||||
return `"${sanitizedTokens.join(" ")}"`;
|
||||
|
||||
case "!=": // Does not contain
|
||||
return `NOT (${sanitizedTokens.join(" OR ")})`;
|
||||
|
||||
case "~=": // Fuzzy match (use OR)
|
||||
case "~*":
|
||||
return sanitizedTokens.join(" OR ");
|
||||
|
||||
case "%=": // Regex - uses traditional SQL iteration fallback
|
||||
throw new FTSQueryError("Regex search not supported in FTS5 - use traditional search path");
|
||||
|
||||
default:
|
||||
throw new FTSQueryError(`Unsupported MATCH operator: ${operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a token for safe use in FTS5 queries
|
||||
* Validates that the token is not empty after sanitization
|
||||
*/
|
||||
export function sanitizeFTS5Token(token: string): string {
|
||||
// Remove special FTS5 characters that could break syntax
|
||||
const sanitized = token
|
||||
.replace(/["\(\)\*]/g, '') // Remove quotes, parens, wildcards
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Validate that token is not empty after sanitization
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
log.info(`Token became empty after sanitization: "${token}"`);
|
||||
// Return a safe placeholder that won't match anything
|
||||
return "__empty_token__";
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes LIKE wildcards (% and _) in user input to treat them as literals
|
||||
* @param str - User input string
|
||||
* @returns String with LIKE wildcards escaped
|
||||
*/
|
||||
export function escapeLikeWildcards(str: string): string {
|
||||
return str.replace(/[%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a phrase appears as exact words in text (respecting word boundaries)
|
||||
* @param phrase - The phrase to search for (case-insensitive)
|
||||
* @param text - The text to search in
|
||||
* @returns true if the phrase appears as complete words, false otherwise
|
||||
*/
|
||||
export function containsExactPhrase(phrase: string, text: string | null | undefined): boolean {
|
||||
if (!text || !phrase || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize both to lowercase for case-insensitive comparison
|
||||
const normalizedPhrase = phrase.toLowerCase().trim();
|
||||
const normalizedText = text.toLowerCase();
|
||||
|
||||
// Strip HTML tags for content matching
|
||||
const plainText = striptags(normalizedText);
|
||||
|
||||
// For single words, use word-boundary matching
|
||||
if (!normalizedPhrase.includes(' ')) {
|
||||
// Split text into words and check for exact match
|
||||
const words = plainText.split(/\s+/);
|
||||
return words.some(word => word === normalizedPhrase);
|
||||
}
|
||||
|
||||
// For multi-word phrases, check if the phrase appears as consecutive words
|
||||
// Split text into words, then check if the phrase appears in the word sequence
|
||||
const textWords = plainText.split(/\s+/);
|
||||
const phraseWords = normalizedPhrase.split(/\s+/);
|
||||
|
||||
// Sliding window to find exact phrase match
|
||||
for (let i = 0; i <= textWords.length - phraseWords.length; i++) {
|
||||
let match = true;
|
||||
for (let j = 0; j < phraseWords.length; j++) {
|
||||
if (textWords[i + j] !== phraseWords[j]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a snippet from content
|
||||
*/
|
||||
export function generateSnippet(content: string, maxLength: number = 30): string {
|
||||
// Strip HTML tags for snippet
|
||||
const plainText = striptags(content);
|
||||
// Simple normalization - just trim and collapse whitespace
|
||||
const normalized = plainText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (normalized.length <= maxLength * 10) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Extract snippet around first occurrence
|
||||
return normalized.substring(0, maxLength * 10) + '...';
|
||||
}
|
||||
655
apps/server/src/services/search/fts/search_service.ts
Normal file
655
apps/server/src/services/search/fts/search_service.ts
Normal file
@ -0,0 +1,655 @@
|
||||
/**
|
||||
* FTS5 Search Service
|
||||
*
|
||||
* Core search operations using SQLite's FTS5 extension with:
|
||||
* - Trigram tokenization for fast substring matching
|
||||
* - Snippet extraction for context
|
||||
* - Highlighting of matched terms
|
||||
* - LIKE-based substring searches
|
||||
* - Protected notes search
|
||||
* - Attribute search
|
||||
*/
|
||||
|
||||
import sql from "../../sql.js";
|
||||
import log from "../../log.js";
|
||||
import protectedSessionService from "../../protected_session.js";
|
||||
import { FTSError, FTSQueryError } from "./errors.js";
|
||||
import { FTS_CONFIG, type FTSSearchResult, type FTSSearchOptions } from "./types.js";
|
||||
import {
|
||||
convertToFTS5Query,
|
||||
sanitizeFTS5Token,
|
||||
escapeLikeWildcards,
|
||||
containsExactPhrase,
|
||||
generateSnippet
|
||||
} from "./query_builder.js";
|
||||
import { filterNonProtectedNoteIds } from "./index_manager.js";
|
||||
|
||||
/**
|
||||
* Performs substring search using LIKE queries optimized by trigram index
|
||||
* This is used for *=*, *=, and =* operators with detail='none'
|
||||
*
|
||||
* @param tokens - Search tokens
|
||||
* @param operator - Search operator (*=*, *=, =*)
|
||||
* @param noteIds - Optional set of note IDs to filter
|
||||
* @param options - Search options
|
||||
* @returns Array of search results (noteIds only, no scoring)
|
||||
*/
|
||||
export function searchWithLike(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {}
|
||||
): FTSSearchResult[] {
|
||||
// Handle empty tokens efficiently - return all notes without running diagnostics
|
||||
if (tokens.length === 0) {
|
||||
// Empty query means return all indexed notes (optionally filtered by noteIds)
|
||||
log.info('[FTS-OPTIMIZATION] Empty token array - returning all indexed notes without diagnostics');
|
||||
|
||||
const results: FTSSearchResult[] = [];
|
||||
let query: string;
|
||||
const params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.size > 0) {
|
||||
const nonProtectedNoteIds = filterNonProtectedNoteIds(noteIds);
|
||||
if (nonProtectedNoteIds.length === 0) {
|
||||
return []; // No non-protected notes to search
|
||||
}
|
||||
query = `SELECT noteId, title FROM notes_fts WHERE noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`;
|
||||
params.push(...nonProtectedNoteIds);
|
||||
} else {
|
||||
// Return all indexed notes
|
||||
query = `SELECT noteId, title FROM notes_fts`;
|
||||
}
|
||||
|
||||
for (const row of sql.iterateRows<{ noteId: string; title: string }>(query, params)) {
|
||||
results.push({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 0, // No ranking for empty query
|
||||
snippet: undefined
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`[FTS-OPTIMIZATION] Empty token search returned ${results.length} results`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Normalize tokens to lowercase for case-insensitive search
|
||||
const normalizedTokens = tokens.map(t => t.toLowerCase());
|
||||
|
||||
// Validate token lengths to prevent memory issues
|
||||
const longTokens = normalizedTokens.filter(t => t.length > FTS_CONFIG.MAX_TOKEN_LENGTH);
|
||||
if (longTokens.length > 0) {
|
||||
throw new FTSQueryError(
|
||||
`Search tokens too long (max ${FTS_CONFIG.MAX_TOKEN_LENGTH} characters). ` +
|
||||
`Long tokens: ${longTokens.map(t => t.substring(0, 50) + '...').join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
limit, // No default limit - return all results
|
||||
offset = 0,
|
||||
skipDiagnostics = false
|
||||
} = options;
|
||||
|
||||
// Run diagnostics BEFORE the actual search (not counted in performance timing)
|
||||
if (!skipDiagnostics) {
|
||||
log.info('[FTS-DIAGNOSTICS] Running index completeness checks (not counted in search timing)...');
|
||||
const totalInFts = sql.getValue<number>(`SELECT COUNT(*) FROM notes_fts`);
|
||||
const totalNotes = sql.getValue<number>(`
|
||||
SELECT COUNT(*)
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
AND n.isDeleted = 0
|
||||
AND n.isProtected = 0
|
||||
AND b.content IS NOT NULL
|
||||
`);
|
||||
|
||||
if (totalInFts < totalNotes) {
|
||||
log.info(`[FTS-DIAGNOSTICS] FTS index incomplete: ${totalInFts} indexed out of ${totalNotes} total notes. Run syncMissingNotes().`);
|
||||
} else {
|
||||
log.info(`[FTS-DIAGNOSTICS] FTS index complete: ${totalInFts} notes indexed`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start timing for actual search (excludes diagnostics)
|
||||
const searchStartTime = Date.now();
|
||||
|
||||
// Optimization: If noteIds set is very large, skip filtering to avoid expensive IN clauses
|
||||
// The FTS table already excludes protected notes, so we can search all notes
|
||||
const isLargeNoteSet = noteIds && noteIds.size > FTS_CONFIG.LARGE_SET_THRESHOLD;
|
||||
|
||||
if (isLargeNoteSet) {
|
||||
log.info(`[FTS-OPTIMIZATION] Large noteIds set (${noteIds!.size} notes) - skipping IN clause filter, searching all FTS notes`);
|
||||
}
|
||||
|
||||
// Only filter noteIds if the set is small enough to benefit from it
|
||||
const shouldFilterByNoteIds = noteIds && noteIds.size > 0 && !isLargeNoteSet;
|
||||
const nonProtectedNoteIds = shouldFilterByNoteIds
|
||||
? filterNonProtectedNoteIds(noteIds)
|
||||
: [];
|
||||
|
||||
let whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build LIKE conditions for each token - search BOTH title and content
|
||||
switch (operator) {
|
||||
case "*=*": // Contains (substring)
|
||||
normalizedTokens.forEach(token => {
|
||||
// Search in BOTH title and content with escaped wildcards
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = escapeLikeWildcards(token);
|
||||
params.push(`%${escapedToken}%`, `%${escapedToken}%`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "*=": // Ends with
|
||||
normalizedTokens.forEach(token => {
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = escapeLikeWildcards(token);
|
||||
params.push(`%${escapedToken}`, `%${escapedToken}`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "=*": // Starts with
|
||||
normalizedTokens.forEach(token => {
|
||||
whereConditions.push(`(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')`);
|
||||
const escapedToken = escapeLikeWildcards(token);
|
||||
params.push(`${escapedToken}%`, `${escapedToken}%`);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new FTSQueryError(`Unsupported LIKE operator: ${operator}`);
|
||||
}
|
||||
|
||||
// Validate that we have search criteria
|
||||
if (whereConditions.length === 0 && nonProtectedNoteIds.length === 0) {
|
||||
throw new FTSQueryError("No search criteria provided (empty tokens and no note filter)");
|
||||
}
|
||||
|
||||
// Add noteId filter if provided
|
||||
if (nonProtectedNoteIds.length > 0) {
|
||||
const tokenParamCount = params.length;
|
||||
const additionalParams = 2; // For limit and offset
|
||||
|
||||
if (nonProtectedNoteIds.length <= FTS_CONFIG.MAX_PARAMS_PER_QUERY - tokenParamCount - additionalParams) {
|
||||
// Normal case: all IDs fit in one query
|
||||
whereConditions.push(`noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`);
|
||||
params.push(...nonProtectedNoteIds);
|
||||
} else {
|
||||
// Large noteIds set: split into chunks and execute multiple queries
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < nonProtectedNoteIds.length; i += FTS_CONFIG.MAX_PARAMS_PER_QUERY) {
|
||||
chunks.push(nonProtectedNoteIds.slice(i, i + FTS_CONFIG.MAX_PARAMS_PER_QUERY));
|
||||
}
|
||||
|
||||
log.info(`Large noteIds set detected (${nonProtectedNoteIds.length} notes), splitting into ${chunks.length} chunks`);
|
||||
|
||||
// Execute a query for each chunk and combine results
|
||||
const allResults: FTSSearchResult[] = [];
|
||||
let remainingLimit = limit !== undefined ? limit : Number.MAX_SAFE_INTEGER;
|
||||
let currentOffset = offset;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (remainingLimit <= 0) break;
|
||||
|
||||
const chunkWhereConditions = [...whereConditions];
|
||||
const chunkParams: any[] = [...params];
|
||||
|
||||
chunkWhereConditions.push(`noteId IN (${chunk.map(() => '?').join(',')})`);
|
||||
chunkParams.push(...chunk);
|
||||
|
||||
// Build chunk query
|
||||
const chunkQuery = `
|
||||
SELECT noteId, title
|
||||
FROM notes_fts
|
||||
WHERE ${chunkWhereConditions.join(' AND ')}
|
||||
${remainingLimit !== Number.MAX_SAFE_INTEGER ? 'LIMIT ?' : ''}
|
||||
${currentOffset > 0 ? 'OFFSET ?' : ''}
|
||||
`;
|
||||
|
||||
if (remainingLimit !== Number.MAX_SAFE_INTEGER) chunkParams.push(remainingLimit);
|
||||
if (currentOffset > 0) chunkParams.push(currentOffset);
|
||||
|
||||
const chunkResults = sql.getRows<{ noteId: string; title: string }>(chunkQuery, chunkParams);
|
||||
allResults.push(...chunkResults.map(row => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 1.0
|
||||
})));
|
||||
|
||||
if (remainingLimit !== Number.MAX_SAFE_INTEGER) {
|
||||
remainingLimit -= chunkResults.length;
|
||||
}
|
||||
currentOffset = 0; // Only apply offset to first chunk
|
||||
}
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 LIKE search (chunked) returned ${allResults.length} results in ${searchTime}ms`);
|
||||
|
||||
return allResults;
|
||||
}
|
||||
}
|
||||
|
||||
// Build query - LIKE queries are automatically optimized by trigram index
|
||||
// Only add LIMIT/OFFSET if specified
|
||||
const query = `
|
||||
SELECT noteId, title
|
||||
FROM notes_fts
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
${limit !== undefined ? 'LIMIT ?' : ''}
|
||||
${offset > 0 ? 'OFFSET ?' : ''}
|
||||
`;
|
||||
|
||||
// Only add limit/offset params if specified
|
||||
if (limit !== undefined) params.push(limit);
|
||||
if (offset > 0) params.push(offset);
|
||||
|
||||
// Log the search parameters
|
||||
log.info(`FTS5 LIKE search: tokens=[${normalizedTokens.join(', ')}], operator=${operator}, limit=${limit || 'none'}, offset=${offset}`);
|
||||
|
||||
const rows = sql.getRows<{ noteId: string; title: string }>(query, params);
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 LIKE search returned ${rows.length} results in ${searchTime}ms`);
|
||||
|
||||
return rows.map(row => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 1.0 // LIKE queries don't have ranking
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`FTS5 LIKE search error: ${error}`);
|
||||
throw new FTSQueryError(
|
||||
`FTS5 LIKE search failed: ${error.message}`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a synchronous full-text search using FTS5
|
||||
*
|
||||
* @param tokens - Search tokens
|
||||
* @param operator - Search operator
|
||||
* @param noteIds - Optional set of note IDs to search within
|
||||
* @param options - Search options
|
||||
* @returns Array of search results
|
||||
*/
|
||||
export function searchSync(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {}
|
||||
): FTSSearchResult[] {
|
||||
// Handle empty tokens efficiently - return all notes without MATCH query
|
||||
if (tokens.length === 0) {
|
||||
log.info('[FTS-OPTIMIZATION] Empty token array in searchSync - returning all indexed notes');
|
||||
|
||||
// Reuse the empty token logic from searchWithLike
|
||||
const results: FTSSearchResult[] = [];
|
||||
let query: string;
|
||||
const params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.size > 0) {
|
||||
const nonProtectedNoteIds = filterNonProtectedNoteIds(noteIds);
|
||||
if (nonProtectedNoteIds.length === 0) {
|
||||
return []; // No non-protected notes to search
|
||||
}
|
||||
query = `SELECT noteId, title FROM notes_fts WHERE noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`;
|
||||
params.push(...nonProtectedNoteIds);
|
||||
} else {
|
||||
// Return all indexed notes
|
||||
query = `SELECT noteId, title FROM notes_fts`;
|
||||
}
|
||||
|
||||
for (const row of sql.iterateRows<{ noteId: string; title: string }>(query, params)) {
|
||||
results.push({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
score: 0, // No ranking for empty query
|
||||
snippet: undefined
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`[FTS-OPTIMIZATION] Empty token search returned ${results.length} results`);
|
||||
return results;
|
||||
}
|
||||
|
||||
const {
|
||||
limit = FTS_CONFIG.DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
includeSnippets = true,
|
||||
snippetLength = FTS_CONFIG.DEFAULT_SNIPPET_LENGTH,
|
||||
highlightTag = FTS_CONFIG.DEFAULT_HIGHLIGHT_START,
|
||||
searchProtected = false
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Start timing for actual search
|
||||
const searchStartTime = Date.now();
|
||||
|
||||
const ftsQuery = convertToFTS5Query(tokens, operator);
|
||||
|
||||
// Validate query length
|
||||
if (ftsQuery.length > FTS_CONFIG.MAX_QUERY_LENGTH) {
|
||||
throw new FTSQueryError(
|
||||
`Query too long: ${ftsQuery.length} characters (max: ${FTS_CONFIG.MAX_QUERY_LENGTH})`,
|
||||
ftsQuery
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we're searching for protected notes
|
||||
// Protected notes are NOT in the FTS index, so we need to handle them separately
|
||||
if (searchProtected && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
log.info("Protected session available - will search protected notes separately");
|
||||
// Return empty results from FTS and let the caller handle protected notes
|
||||
// The caller should use a fallback search method for protected notes
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build the SQL query
|
||||
let whereConditions = [`notes_fts MATCH ?`];
|
||||
const params: any[] = [ftsQuery];
|
||||
|
||||
// Optimization: If noteIds set is very large, skip filtering to avoid expensive IN clauses
|
||||
// The FTS table already excludes protected notes, so we can search all notes
|
||||
const isLargeNoteSet = noteIds && noteIds.size > FTS_CONFIG.LARGE_SET_THRESHOLD;
|
||||
|
||||
if (isLargeNoteSet) {
|
||||
log.info(`[FTS-OPTIMIZATION] Large noteIds set (${noteIds!.size} notes) - skipping IN clause filter, searching all FTS notes`);
|
||||
}
|
||||
|
||||
// Filter by noteIds if provided and set is small enough
|
||||
const shouldFilterByNoteIds = noteIds && noteIds.size > 0 && !isLargeNoteSet;
|
||||
if (shouldFilterByNoteIds) {
|
||||
// First filter out any protected notes from the noteIds
|
||||
const nonProtectedNoteIds = filterNonProtectedNoteIds(noteIds!);
|
||||
if (nonProtectedNoteIds.length === 0) {
|
||||
// All provided notes are protected, return empty results
|
||||
return [];
|
||||
}
|
||||
whereConditions.push(`noteId IN (${nonProtectedNoteIds.map(() => '?').join(',')})`);
|
||||
params.push(...nonProtectedNoteIds);
|
||||
}
|
||||
|
||||
// Build snippet extraction if requested
|
||||
const snippetSelect = includeSnippets
|
||||
? `, snippet(notes_fts, ${FTS_CONFIG.SNIPPET_COLUMN_CONTENT}, '${highlightTag}', '${highlightTag.replace('<', '</')}', '...', ${snippetLength}) as snippet`
|
||||
: '';
|
||||
|
||||
// For exact match (=), include content for post-filtering word boundaries
|
||||
const contentSelect = operator === "=" ? ', content' : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
noteId,
|
||||
title,
|
||||
rank as score
|
||||
${snippetSelect}
|
||||
${contentSelect}
|
||||
FROM notes_fts
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
ORDER BY rank
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
let results = sql.getRows<{
|
||||
noteId: string;
|
||||
title: string;
|
||||
score: number;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
}>(query, params);
|
||||
|
||||
// Post-filter for exact match operator (=) to handle word boundaries
|
||||
// Trigram FTS5 doesn't respect word boundaries in phrase queries,
|
||||
// so "test123" matches "test1234" due to shared trigrams.
|
||||
// We need to post-filter results to only include exact word matches.
|
||||
if (operator === "=") {
|
||||
const phrase = tokens.join(" ");
|
||||
results = results.filter(result => {
|
||||
// Use content from result if available, otherwise fetch it
|
||||
let noteContent = result.content;
|
||||
if (!noteContent) {
|
||||
noteContent = sql.getValue<string>(`
|
||||
SELECT b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE n.noteId = ?
|
||||
`, [result.noteId]);
|
||||
}
|
||||
|
||||
if (!noteContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if phrase appears as exact words in content or title
|
||||
return containsExactPhrase(phrase, result.title) ||
|
||||
containsExactPhrase(phrase, noteContent);
|
||||
});
|
||||
}
|
||||
|
||||
const searchTime = Date.now() - searchStartTime;
|
||||
log.info(`FTS5 MATCH search returned ${results.length} results in ${searchTime}ms`);
|
||||
|
||||
return results;
|
||||
|
||||
} catch (error: any) {
|
||||
// Provide structured error information
|
||||
if (error instanceof FTSError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.error(`FTS5 search error: ${error}`);
|
||||
|
||||
// Determine if this is a recoverable error
|
||||
const isRecoverable =
|
||||
error.message?.includes('syntax error') ||
|
||||
error.message?.includes('malformed MATCH') ||
|
||||
error.message?.includes('no such table');
|
||||
|
||||
throw new FTSQueryError(
|
||||
`FTS5 search failed: ${error.message}. ${isRecoverable ? 'Falling back to standard search.' : ''}`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches attributes using FTS5
|
||||
* Returns noteIds of notes that have matching attributes
|
||||
*/
|
||||
export function searchAttributesSync(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>
|
||||
): Set<string> {
|
||||
// Check if attributes_fts table exists
|
||||
const tableExists = sql.getValue<number>(`
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type='table' AND name='attributes_fts'
|
||||
`);
|
||||
|
||||
if (!tableExists) {
|
||||
log.info("attributes_fts table does not exist - skipping FTS attribute search");
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
// Sanitize tokens to prevent FTS5 syntax injection
|
||||
const sanitizedTokens = tokens.map(token => sanitizeFTS5Token(token));
|
||||
|
||||
// Check if any tokens became invalid after sanitization
|
||||
if (sanitizedTokens.some(t => t === '__empty_token__' || t === '__invalid_token__')) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const phrase = sanitizedTokens.join(" ");
|
||||
|
||||
// Build FTS5 query for exact match
|
||||
const ftsQuery = operator === "=" ? `"${phrase}"` : phrase;
|
||||
|
||||
// Search both name and value columns
|
||||
const whereConditions: string[] = [
|
||||
`attributes_fts MATCH '${ftsQuery.replace(/'/g, "''")}'`
|
||||
];
|
||||
|
||||
const params: any[] = [];
|
||||
|
||||
// Filter by noteIds if provided
|
||||
if (noteIds && noteIds.size > 0 && noteIds.size < 1000) {
|
||||
const noteIdList = Array.from(noteIds);
|
||||
whereConditions.push(`noteId IN (${noteIdList.map(() => '?').join(',')})`);
|
||||
params.push(...noteIdList);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT DISTINCT noteId, name, value
|
||||
FROM attributes_fts
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
`;
|
||||
|
||||
const results = sql.getRows<{
|
||||
noteId: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}>(query, params);
|
||||
|
||||
// Post-filter for exact word matches when operator is "="
|
||||
if (operator === "=") {
|
||||
const matchingNoteIds = new Set<string>();
|
||||
for (const result of results) {
|
||||
// Check if phrase matches attribute name or value with word boundaries
|
||||
// For attribute names, check exact match (attribute name "test125" matches search "test125")
|
||||
// For attribute values, check if phrase appears as exact words
|
||||
const nameMatch = result.name.toLowerCase() === phrase.toLowerCase();
|
||||
const valueMatch = result.value ? containsExactPhrase(phrase, result.value) : false;
|
||||
|
||||
if (nameMatch || valueMatch) {
|
||||
matchingNoteIds.add(result.noteId);
|
||||
}
|
||||
}
|
||||
return matchingNoteIds;
|
||||
}
|
||||
|
||||
// For other operators, return all matching noteIds
|
||||
const matchingNoteIds = new Set(results.map(r => r.noteId));
|
||||
return matchingNoteIds;
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`FTS5 attribute search error: ${error}`);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches protected notes separately (not in FTS index)
|
||||
* This is a fallback method for protected notes
|
||||
*/
|
||||
export function searchProtectedNotesSync(
|
||||
tokens: string[],
|
||||
operator: string,
|
||||
noteIds?: Set<string>,
|
||||
options: FTSSearchOptions = {}
|
||||
): FTSSearchResult[] {
|
||||
if (!protectedSessionService.isProtectedSessionAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
limit = FTS_CONFIG.DEFAULT_LIMIT,
|
||||
offset = 0
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Build query for protected notes only
|
||||
let whereConditions = [`n.isProtected = 1`, `n.isDeleted = 0`];
|
||||
const params: any[] = [];
|
||||
|
||||
if (noteIds && noteIds.size > 0) {
|
||||
const noteIdList = Array.from(noteIds);
|
||||
whereConditions.push(`n.noteId IN (${noteIdList.map(() => '?').join(',')})`);
|
||||
params.push(...noteIdList);
|
||||
}
|
||||
|
||||
// Get protected notes
|
||||
const protectedNotes = sql.getRows<{
|
||||
noteId: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
}>(`
|
||||
SELECT n.noteId, n.title, b.content
|
||||
FROM notes n
|
||||
LEFT JOIN blobs b ON n.blobId = b.blobId
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||
LIMIT ? OFFSET ?
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
const results: FTSSearchResult[] = [];
|
||||
|
||||
for (const note of protectedNotes) {
|
||||
if (!note.content) continue;
|
||||
|
||||
try {
|
||||
// Decrypt content
|
||||
const decryptedContent = protectedSessionService.decryptString(note.content);
|
||||
if (!decryptedContent) continue;
|
||||
|
||||
// Simple token matching for protected notes
|
||||
const contentLower = decryptedContent.toLowerCase();
|
||||
const titleLower = note.title.toLowerCase();
|
||||
let matches = false;
|
||||
|
||||
switch (operator) {
|
||||
case "=": // Exact match
|
||||
const phrase = tokens.join(' ').toLowerCase();
|
||||
matches = contentLower.includes(phrase) || titleLower.includes(phrase);
|
||||
break;
|
||||
case "*=*": // Contains all tokens
|
||||
matches = tokens.every(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
break;
|
||||
case "~=": // Contains any token
|
||||
case "~*":
|
||||
matches = tokens.some(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
break;
|
||||
default:
|
||||
matches = tokens.every(token =>
|
||||
contentLower.includes(token.toLowerCase()) ||
|
||||
titleLower.includes(token.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
results.push({
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
score: 1.0, // Simple scoring for protected notes
|
||||
snippet: generateSnippet(decryptedContent)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.info(`Could not decrypt protected note ${note.noteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error: any) {
|
||||
log.error(`Protected notes search error: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
62
apps/server/src/services/search/fts/types.ts
Normal file
62
apps/server/src/services/search/fts/types.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* FTS5 Types and Configuration
|
||||
*
|
||||
* Shared interfaces and configuration constants for FTS5 operations.
|
||||
*/
|
||||
|
||||
import type { FTSError } from "./errors.js";
|
||||
|
||||
export interface FTSSearchResult {
|
||||
noteId: string;
|
||||
title: string;
|
||||
score: number;
|
||||
snippet?: string;
|
||||
highlights?: string[];
|
||||
}
|
||||
|
||||
export interface FTSSearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeSnippets?: boolean;
|
||||
snippetLength?: number;
|
||||
highlightTag?: string;
|
||||
searchProtected?: boolean;
|
||||
skipDiagnostics?: boolean;
|
||||
}
|
||||
|
||||
export interface FTSErrorInfo {
|
||||
error: FTSError;
|
||||
fallbackUsed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FTSIndexStats {
|
||||
totalDocuments: number;
|
||||
indexSize: number;
|
||||
isOptimized: boolean;
|
||||
dbstatAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for FTS5 search operations
|
||||
*/
|
||||
export const FTS_CONFIG = {
|
||||
/** Maximum number of results to return by default */
|
||||
DEFAULT_LIMIT: 100,
|
||||
/** Default snippet length in tokens */
|
||||
DEFAULT_SNIPPET_LENGTH: 30,
|
||||
/** Default highlight tags */
|
||||
DEFAULT_HIGHLIGHT_START: '<mark>',
|
||||
DEFAULT_HIGHLIGHT_END: '</mark>',
|
||||
/** Maximum query length to prevent DoS */
|
||||
MAX_QUERY_LENGTH: 1000,
|
||||
/** Maximum token length to prevent memory issues */
|
||||
MAX_TOKEN_LENGTH: 1000,
|
||||
/** Threshold for considering a noteIds set as "large" */
|
||||
LARGE_SET_THRESHOLD: 1000,
|
||||
/** SQLite parameter limit (with margin) */
|
||||
MAX_PARAMS_PER_QUERY: 900,
|
||||
/** Snippet column indices */
|
||||
SNIPPET_COLUMN_TITLE: 1,
|
||||
SNIPPET_COLUMN_CONTENT: 2,
|
||||
} as const;
|
||||
@ -14,12 +14,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { ftsSearchService } from "./fts_search.js";
|
||||
import { ftsSearchService, FTSError, FTSQueryError, convertToFTS5Query } from "./fts/index.js";
|
||||
import searchService from "./services/search.js";
|
||||
import BNote from "../../becca/entities/bnote.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import SearchContext from "./search_context.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import cls from "../cls.js";
|
||||
import sql from "../sql.js";
|
||||
import { note, NoteBuilder } from "../../test/becca_mocking.js";
|
||||
import {
|
||||
searchNote,
|
||||
@ -52,40 +54,37 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("FTS5 Availability", () => {
|
||||
it.skip("should detect FTS5 availability (requires FTS5 integration test setup)", () => {
|
||||
// TODO: This is an integration test that requires actual FTS5 database setup
|
||||
// The current test infrastructure doesn't support direct FTS5 method calls
|
||||
// These tests validate FTS5 functionality but need proper integration test environment
|
||||
it("should detect FTS5 availability", () => {
|
||||
const isAvailable = ftsSearchService.checkFTS5Availability();
|
||||
expect(typeof isAvailable).toBe("boolean");
|
||||
});
|
||||
|
||||
it.skip("should cache FTS5 availability check (requires FTS5 integration test setup)", () => {
|
||||
// TODO: This is an integration test that requires actual FTS5 database setup
|
||||
// The current test infrastructure doesn't support direct FTS5 method calls
|
||||
// These tests validate FTS5 functionality but need proper integration test environment
|
||||
it("should cache FTS5 availability check", () => {
|
||||
const first = ftsSearchService.checkFTS5Availability();
|
||||
const second = ftsSearchService.checkFTS5Availability();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it.todo("should provide meaningful error when FTS5 not available", () => {
|
||||
// This test would need to mock sql.getValue to simulate FTS5 unavailability
|
||||
// Implementation depends on actual mocking strategy
|
||||
expect(true).toBe(true); // Placeholder
|
||||
it("should provide meaningful error when FTS5 not available", () => {
|
||||
// Test that assertFTS5Available throws a meaningful error when FTS5 table is missing
|
||||
// We can't actually remove the table, but we can test the error class behavior
|
||||
const error = new FTSError("FTS5 table 'notes_fts' not found", "FTS_NOT_AVAILABLE", false);
|
||||
|
||||
expect(error.message).toContain("notes_fts");
|
||||
expect(error.code).toBe("FTS_NOT_AVAILABLE");
|
||||
expect(error.recoverable).toBe(false);
|
||||
expect(error.name).toBe("FTSError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query Execution", () => {
|
||||
it.skip("should execute basic exact match query (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should execute basic exact match query", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Document One", "This contains the search term."))
|
||||
.child(contentNote("Document Two", "Another search term here."))
|
||||
.child(contentNote("Different", "No matching words."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("search term", searchContext);
|
||||
@ -97,15 +96,13 @@ describe("FTS5 Integration Tests", () => {
|
||||
.doesNotHaveTitle("Different");
|
||||
});
|
||||
|
||||
it.skip("should handle multiple tokens with AND logic (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle multiple tokens with AND logic", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Both", "Contains search and term together."))
|
||||
.child(contentNote("Only Search", "Contains search only."))
|
||||
.child(contentNote("Only Term", "Contains term only."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("search term", searchContext);
|
||||
@ -114,18 +111,17 @@ describe("FTS5 Integration Tests", () => {
|
||||
assertContainsTitle(results, "Both");
|
||||
});
|
||||
|
||||
it.skip("should support OR operator (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should support OR operator", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("First", "Contains alpha."))
|
||||
.child(contentNote("Second", "Contains beta."))
|
||||
.child(contentNote("Neither", "Contains gamma."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("alpha OR beta", searchContext);
|
||||
// Use note.content with OR syntax
|
||||
const results = searchService.findResultsWithQuery("note.content *=* alpha OR note.content *=* beta", searchContext);
|
||||
|
||||
expectResults(results)
|
||||
.hasMinCount(2)
|
||||
@ -134,15 +130,13 @@ describe("FTS5 Integration Tests", () => {
|
||||
.doesNotHaveTitle("Neither");
|
||||
});
|
||||
|
||||
it.skip("should support NOT operator (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should support NOT operator", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Included", "Contains positive but not negative."))
|
||||
.child(contentNote("Excluded", "Contains positive and negative."))
|
||||
.child(contentNote("Neither", "Contains neither."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("positive NOT negative", searchContext);
|
||||
@ -153,14 +147,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
.doesNotHaveTitle("Excluded");
|
||||
});
|
||||
|
||||
it.skip("should handle phrase search with quotes (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle phrase search with quotes", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Exact", 'Contains "exact phrase" in order.'))
|
||||
.child(contentNote("Scrambled", "Contains phrase exact in wrong order."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery('"exact phrase"', searchContext);
|
||||
@ -171,14 +163,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
.doesNotHaveTitle("Scrambled");
|
||||
});
|
||||
|
||||
it.skip("should enforce minimum token length of 3 characters (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should enforce minimum token length of 3 characters", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Short", "Contains ab and xy tokens."))
|
||||
.child(contentNote("Long", "Contains abc and xyz tokens."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -194,14 +184,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Content Size Limits", () => {
|
||||
it.skip("should handle notes up to 10MB content size (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle notes up to 10MB content size", () => {
|
||||
cls.init(() => {
|
||||
// Create a note with large content (but less than 10MB)
|
||||
const largeContent = "test ".repeat(100000); // ~500KB
|
||||
rootNote.child(contentNote("Large Note", largeContent));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("test", searchContext);
|
||||
@ -209,16 +197,14 @@ describe("FTS5 Integration Tests", () => {
|
||||
expectResults(results).hasMinCount(1).hasTitle("Large Note");
|
||||
});
|
||||
|
||||
it.skip("should still find notes exceeding 10MB by title (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should still find notes exceeding 10MB by title", () => {
|
||||
cls.init(() => {
|
||||
// Create a note with very large content (simulate >10MB)
|
||||
const veryLargeContent = "x".repeat(11 * 1024 * 1024); // 11MB
|
||||
const largeNote = searchNote("Oversized Note");
|
||||
largeNote.content(veryLargeContent);
|
||||
rootNote.child(largeNote);
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -227,12 +213,10 @@ describe("FTS5 Integration Tests", () => {
|
||||
expectResults(results).hasMinCount(1).hasTitle("Oversized Note");
|
||||
});
|
||||
|
||||
it.skip("should handle empty content gracefully (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle empty content gracefully", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Empty Note", ""));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("Empty", searchContext);
|
||||
@ -242,14 +226,26 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Protected Notes Handling", () => {
|
||||
it.skip("should not index protected notes in FTS5 (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should not index protected notes in FTS5", () => {
|
||||
// Protected notes require an active protected session to set content
|
||||
// We test with a note marked as protected but without content
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Public", "This is public content."))
|
||||
.child(protectedNote("Secret", "This is secret content."));
|
||||
.child(contentNote("Public", "This is public content."));
|
||||
// Create a protected note without setting content (would require session)
|
||||
const protNote = new SearchTestNoteBuilder(new BNote({
|
||||
noteId: `prot_${Date.now()}`,
|
||||
title: "Secret",
|
||||
type: "text",
|
||||
isProtected: true
|
||||
}));
|
||||
new BBranch({
|
||||
branchId: `branch_prot_${Date.now()}`,
|
||||
noteId: protNote.note.noteId,
|
||||
parentNoteId: rootNote.note.noteId,
|
||||
notePosition: 20
|
||||
});
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext({ includeArchivedNotes: false });
|
||||
const results = searchService.findResultsWithQuery("content", searchContext);
|
||||
@ -258,25 +254,39 @@ describe("FTS5 Integration Tests", () => {
|
||||
assertNoProtectedNotes(results);
|
||||
});
|
||||
|
||||
it.todo("should search protected notes separately when session available", () => {
|
||||
const publicNote = contentNote("Public", "Contains keyword.");
|
||||
const secretNote = protectedNote("Secret", "Contains keyword.");
|
||||
it("should search protected notes separately when session available", () => {
|
||||
// Test that the searchProtectedNotesSync function exists and returns empty
|
||||
// when no protected session is available (which is the case in tests)
|
||||
const results = ftsSearchService.searchProtectedNotesSync(
|
||||
["test"],
|
||||
"*=*",
|
||||
undefined,
|
||||
{}
|
||||
);
|
||||
|
||||
rootNote.child(publicNote).child(secretNote);
|
||||
|
||||
// This would require mocking protectedSessionService
|
||||
// to simulate an active protected session
|
||||
expect(true).toBe(true); // Placeholder for actual test
|
||||
// Without an active protected session, should return empty array
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it.skip("should exclude protected notes from results by default (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should exclude protected notes from results by default", () => {
|
||||
// Test that protected notes (by isProtected flag) are excluded
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Normal", "Regular content."))
|
||||
.child(protectedNote("Protected", "Protected content."));
|
||||
.child(contentNote("Normal", "Regular content."));
|
||||
// Create a protected note without setting content
|
||||
const protNote = new SearchTestNoteBuilder(new BNote({
|
||||
noteId: `prot2_${Date.now()}`,
|
||||
title: "Protected",
|
||||
type: "text",
|
||||
isProtected: true
|
||||
}));
|
||||
new BBranch({
|
||||
branchId: `branch_prot2_${Date.now()}`,
|
||||
noteId: protNote.note.noteId,
|
||||
parentNoteId: rootNote.note.noteId,
|
||||
notePosition: 20
|
||||
});
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("content", searchContext);
|
||||
@ -286,28 +296,24 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Query Syntax Conversion", () => {
|
||||
it.skip("should convert exact match operator (=) (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should convert exact match operator (=)", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Test", "This is a test document."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
// Search with fulltext operator (FTS5 searches content by default)
|
||||
const results = searchService.findResultsWithQuery('note *=* test', searchContext);
|
||||
// Search with content contains operator
|
||||
const results = searchService.findResultsWithQuery('note.content *=* test', searchContext);
|
||||
|
||||
expectResults(results).hasMinCount(1);
|
||||
});
|
||||
|
||||
it.skip("should convert contains operator (*=*) (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should convert contains operator (*=*)", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Match", "Contains search keyword."))
|
||||
.child(contentNote("No Match", "Different content."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("note.content *=* search", searchContext);
|
||||
@ -317,14 +323,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
.hasTitle("Match");
|
||||
});
|
||||
|
||||
it.skip("should convert starts-with operator (=*) (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should convert starts-with operator (=*)", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Starts", "Testing starts with keyword."))
|
||||
.child(contentNote("Ends", "Keyword at the end Testing."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("note.content =* Testing", searchContext);
|
||||
@ -334,14 +338,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
.hasTitle("Starts");
|
||||
});
|
||||
|
||||
it.skip("should convert ends-with operator (*=) (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should convert ends-with operator (*=)", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Ends", "Content ends with Testing"))
|
||||
.child(contentNote("Starts", "Testing starts here"));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("note.content *= Testing", searchContext);
|
||||
@ -351,30 +353,28 @@ describe("FTS5 Integration Tests", () => {
|
||||
.hasTitle("Ends");
|
||||
});
|
||||
|
||||
it.skip("should handle not-equals operator (!=) (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle not-equals operator (!=)", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Includes", "Contains excluded term."))
|
||||
.child(contentNote("Clean", "Does not contain excluded term."));
|
||||
.child(contentNote("Clean", "Does not contain the bad word."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery('note.content != "excluded"', searchContext);
|
||||
// != operator checks that content does NOT contain the value
|
||||
// This will return notes where content doesn't contain "excluded"
|
||||
const results = searchService.findResultsWithQuery('note.content != excluded', searchContext);
|
||||
|
||||
// Should not find notes containing "excluded"
|
||||
// Should find Clean since it doesn't contain "excluded"
|
||||
assertContainsTitle(results, "Clean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Sanitization", () => {
|
||||
it.skip("should sanitize tokens with special FTS5 characters (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should sanitize tokens with special FTS5 characters", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Test", "Contains special (characters) here."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("special (characters)", searchContext);
|
||||
@ -383,12 +383,10 @@ describe("FTS5 Integration Tests", () => {
|
||||
expectResults(results).hasMinCount(1);
|
||||
});
|
||||
|
||||
it.skip("should handle tokens with quotes (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle tokens with quotes", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Quotes", 'Contains "quoted text" here.'));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery('"quoted text"', searchContext);
|
||||
@ -396,12 +394,10 @@ describe("FTS5 Integration Tests", () => {
|
||||
expectResults(results).hasMinCount(1).hasTitle("Quotes");
|
||||
});
|
||||
|
||||
it.skip("should prevent SQL injection attempts (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should prevent SQL injection attempts", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Safe", "Normal content."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -414,11 +410,7 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should handle empty tokens after sanitization (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle empty tokens after sanitization", () => {
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Token with only special characters
|
||||
@ -430,11 +422,8 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Snippet Extraction", () => {
|
||||
it.skip("should extract snippets from matching content (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should extract snippets from matching content", () => {
|
||||
cls.init(() => {
|
||||
const longContent = `
|
||||
This is a long document with many paragraphs.
|
||||
The keyword appears here in the middle of the text.
|
||||
@ -443,36 +432,27 @@ describe("FTS5 Integration Tests", () => {
|
||||
`;
|
||||
|
||||
rootNote.child(contentNote("Long Document", longContent));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("keyword", searchContext);
|
||||
|
||||
expectResults(results).hasMinCount(1);
|
||||
|
||||
// Snippet should contain surrounding context
|
||||
// (Implementation depends on SearchResult structure)
|
||||
});
|
||||
|
||||
it.skip("should highlight matched terms in snippets (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should highlight matched terms in snippets", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Highlight Test", "This contains the search term to highlight."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("search", searchContext);
|
||||
|
||||
expectResults(results).hasMinCount(1);
|
||||
// Check that highlight markers are present
|
||||
// (Implementation depends on SearchResult structure)
|
||||
});
|
||||
|
||||
it.skip("should extract multiple snippets for multiple matches (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should extract multiple snippets for multiple matches", () => {
|
||||
cls.init(() => {
|
||||
const content = `
|
||||
First occurrence of keyword here.
|
||||
Some other content in between.
|
||||
@ -482,41 +462,34 @@ describe("FTS5 Integration Tests", () => {
|
||||
`;
|
||||
|
||||
rootNote.child(contentNote("Multiple Matches", content));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("keyword", searchContext);
|
||||
|
||||
expectResults(results).hasMinCount(1);
|
||||
// Should have multiple snippets or combined snippet
|
||||
});
|
||||
|
||||
it.skip("should respect snippet length limits (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should respect snippet length limits", () => {
|
||||
cls.init(() => {
|
||||
const veryLongContent = "word ".repeat(10000) + "target " + "word ".repeat(10000);
|
||||
|
||||
rootNote.child(contentNote("Very Long", veryLongContent));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("target", searchContext);
|
||||
|
||||
expectResults(results).hasMinCount(1);
|
||||
// Snippet should not include entire document
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chunking for Large Content", () => {
|
||||
it.skip("should chunk content exceeding size limits (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should chunk content exceeding size limits", () => {
|
||||
cls.init(() => {
|
||||
// Create content that would need chunking
|
||||
const chunkContent = "searchable ".repeat(5000); // Large repeated content
|
||||
|
||||
rootNote.child(contentNote("Chunked", chunkContent));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("searchable", searchContext);
|
||||
@ -524,17 +497,15 @@ describe("FTS5 Integration Tests", () => {
|
||||
expectResults(results).hasMinCount(1).hasTitle("Chunked");
|
||||
});
|
||||
|
||||
it.skip("should search across all chunks (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should search across all chunks", () => {
|
||||
cls.init(() => {
|
||||
// Create content where matches appear in different "chunks"
|
||||
const part1 = "alpha ".repeat(1000);
|
||||
const part2 = "beta ".repeat(1000);
|
||||
const combined = part1 + part2;
|
||||
|
||||
rootNote.child(contentNote("Multi-Chunk", combined));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -548,12 +519,10 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Error Handling and Recovery", () => {
|
||||
it.skip("should handle malformed queries gracefully (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle malformed queries gracefully", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Test", "Normal content."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -564,17 +533,35 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it.todo("should provide meaningful error messages", () => {
|
||||
// This would test FTSError classes and error recovery
|
||||
expect(true).toBe(true); // Placeholder
|
||||
it("should provide meaningful error messages", () => {
|
||||
// Test FTSQueryError provides meaningful information
|
||||
const queryError = new FTSQueryError("Invalid query syntax", "SELECT * FROM");
|
||||
expect(queryError.message).toBe("Invalid query syntax");
|
||||
expect(queryError.query).toBe("SELECT * FROM");
|
||||
expect(queryError.code).toBe("FTS_QUERY_ERROR");
|
||||
expect(queryError.recoverable).toBe(true);
|
||||
expect(queryError.name).toBe("FTSQueryError");
|
||||
|
||||
// Test that convertToFTS5Query throws meaningful errors for invalid operators
|
||||
expect(() => {
|
||||
convertToFTS5Query(["test"], "invalid_operator");
|
||||
}).toThrow(/Unsupported MATCH operator/);
|
||||
|
||||
// Test that short tokens throw meaningful errors
|
||||
expect(() => {
|
||||
convertToFTS5Query(["ab"], "=");
|
||||
}).toThrow(/Trigram tokenizer requires tokens of at least 3 characters/);
|
||||
|
||||
// Test that regex operator throws meaningful error
|
||||
expect(() => {
|
||||
convertToFTS5Query(["test"], "%=");
|
||||
}).toThrow(/Regex search not supported in FTS5/);
|
||||
});
|
||||
|
||||
it.skip("should fall back to non-FTS search on FTS errors (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should fall back to non-FTS search on FTS errors", () => {
|
||||
cls.init(() => {
|
||||
rootNote.child(contentNote("Fallback", "Content for fallback test."));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
@ -586,15 +573,7 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Index Management", () => {
|
||||
it.skip("should provide index statistics (requires FTS5 integration test setup)", () => {
|
||||
// TODO: This is an integration test that requires actual FTS5 database setup
|
||||
// The current test infrastructure doesn't support direct FTS5 method calls
|
||||
// These tests validate FTS5 functionality but need proper integration test environment
|
||||
rootNote
|
||||
.child(contentNote("Doc 1", "Content 1"))
|
||||
.child(contentNote("Doc 2", "Content 2"))
|
||||
.child(contentNote("Doc 3", "Content 3"));
|
||||
|
||||
it("should provide index statistics", () => {
|
||||
// Get FTS index stats
|
||||
const stats = ftsSearchService.getIndexStats();
|
||||
|
||||
@ -602,39 +581,61 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(stats.totalDocuments).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.todo("should handle index optimization", () => {
|
||||
rootNote.child(contentNote("Before Optimize", "Content to index."));
|
||||
it("should handle index optimization", () => {
|
||||
// Test that the FTS5 optimize command works without errors
|
||||
// The optimize command is a special FTS5 operation that merges segments
|
||||
cls.init(() => {
|
||||
// Add some notes to the index
|
||||
rootNote.child(contentNote("Optimize Test 1", "Content to be optimized."));
|
||||
rootNote.child(contentNote("Optimize Test 2", "More content to optimize."));
|
||||
});
|
||||
|
||||
// Note: optimizeIndex() method doesn't exist in ftsSearchService
|
||||
// FTS5 manages optimization internally via the 'optimize' command
|
||||
// This test should either call the internal FTS5 optimize directly
|
||||
// or test the syncMissingNotes() method which triggers optimization
|
||||
// Run the optimize command directly via SQL
|
||||
// This is what rebuildIndex() does internally
|
||||
expect(() => {
|
||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still search correctly after optimization
|
||||
// Verify the index still works after optimization
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("index", searchContext);
|
||||
|
||||
const results = searchService.findResultsWithQuery("optimize", searchContext);
|
||||
expectResults(results).hasMinCount(1);
|
||||
});
|
||||
|
||||
it.todo("should detect when index needs rebuilding", () => {
|
||||
// Note: needsIndexRebuild() method doesn't exist in ftsSearchService
|
||||
// This test should be implemented when the method is added to the service
|
||||
// For now, we can test syncMissingNotes() which serves a similar purpose
|
||||
expect(true).toBe(true);
|
||||
it("should detect when index needs rebuilding", () => {
|
||||
// Test syncMissingNotes which detects and fixes missing index entries
|
||||
cls.init(() => {
|
||||
// Create a note that will be in becca but may not be in FTS
|
||||
rootNote.child(contentNote("Sync Test Note", "Content that should be indexed."));
|
||||
});
|
||||
|
||||
// Get initial stats
|
||||
const statsBefore = ftsSearchService.getIndexStats();
|
||||
|
||||
// syncMissingNotes returns the number of notes that were added to the index
|
||||
// If the triggers are working correctly, this should be 0 since notes
|
||||
// are automatically indexed. But this tests the function works.
|
||||
const syncedCount = ftsSearchService.syncMissingNotes();
|
||||
|
||||
// syncMissingNotes should return a number (0 or more)
|
||||
expect(typeof syncedCount).toBe("number");
|
||||
expect(syncedCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Verify we can still search after sync
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("Sync Test", searchContext);
|
||||
expectResults(results).hasMinCount(1).hasTitle("Sync Test Note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Limits", () => {
|
||||
it.skip("should handle large result sets efficiently (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should handle large result sets efficiently", () => {
|
||||
cls.init(() => {
|
||||
// Create many matching notes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
rootNote.child(contentNote(`Document ${i}`, `Contains searchterm in document ${i}.`));
|
||||
}
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const startTime = Date.now();
|
||||
@ -649,11 +650,7 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it.skip("should respect query length limits (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should respect query length limits", () => {
|
||||
const searchContext = new SearchContext();
|
||||
|
||||
// Very long query should be handled
|
||||
@ -663,14 +660,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(results).toBeDefined();
|
||||
});
|
||||
|
||||
it.skip("should apply limit to results (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should apply limit to results", () => {
|
||||
cls.init(() => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
rootNote.child(contentNote(`Note ${i}`, "matching content"));
|
||||
}
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("matching limit 10", searchContext);
|
||||
@ -680,14 +675,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Integration with Search Context", () => {
|
||||
it.skip("should respect fast search flag (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should respect fast search flag", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Title Match", "Different content"))
|
||||
.child(contentNote("Different Title", "Matching content"));
|
||||
});
|
||||
|
||||
const fastContext = new SearchContext({ fastSearch: true });
|
||||
const results = searchService.findResultsWithQuery("content", fastContext);
|
||||
@ -696,15 +689,13 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(results).toBeDefined();
|
||||
});
|
||||
|
||||
it.skip("should respect includeArchivedNotes flag (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should respect includeArchivedNotes flag", () => {
|
||||
cls.init(() => {
|
||||
const archived = searchNote("Archived").label("archived", "", true);
|
||||
archived.content("Archived content");
|
||||
|
||||
rootNote.child(archived);
|
||||
});
|
||||
|
||||
// Without archived flag
|
||||
const normalContext = new SearchContext({ includeArchivedNotes: false });
|
||||
@ -718,11 +709,8 @@ describe("FTS5 Integration Tests", () => {
|
||||
expect(results2.length).toBeGreaterThanOrEqual(results1.length);
|
||||
});
|
||||
|
||||
it.skip("should respect ancestor filtering (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should respect ancestor filtering", () => {
|
||||
cls.init(() => {
|
||||
const europe = searchNote("Europe");
|
||||
const austria = contentNote("Austria", "European country");
|
||||
const asia = searchNote("Asia");
|
||||
@ -730,24 +718,26 @@ describe("FTS5 Integration Tests", () => {
|
||||
|
||||
rootNote.child(europe.child(austria));
|
||||
rootNote.child(asia.child(japan));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext({ ancestorNoteId: europe.note.noteId });
|
||||
const europeNote = becca.notes[Object.keys(becca.notes).find(id => becca.notes[id]?.title === "Europe") || ""];
|
||||
if (europeNote) {
|
||||
const searchContext = new SearchContext({ ancestorNoteId: europeNote.noteId });
|
||||
const results = searchService.findResultsWithQuery("country", searchContext);
|
||||
|
||||
// Should only find notes under Europe
|
||||
expectResults(results)
|
||||
.hasTitle("Austria")
|
||||
.doesNotHaveTitle("Japan");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Search Fixtures", () => {
|
||||
it.skip("should work with full text search fixture (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
const fixture = createFullTextSearchFixture(rootNote);
|
||||
it("should work with full text search fixture", () => {
|
||||
cls.init(() => {
|
||||
createFullTextSearchFixture(rootNote);
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("search", searchContext);
|
||||
@ -758,14 +748,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Result Quality", () => {
|
||||
it.skip("should not return duplicate results (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should not return duplicate results", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Duplicate Test", "keyword keyword keyword"))
|
||||
.child(contentNote("Another", "keyword"));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("keyword", searchContext);
|
||||
@ -773,14 +761,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
assertNoDuplicates(results);
|
||||
});
|
||||
|
||||
it.skip("should rank exact title matches higher (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should rank exact title matches higher", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Exact", "Other content"))
|
||||
.child(contentNote("Different", "Contains Exact in content"));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("Exact", searchContext);
|
||||
@ -796,14 +782,12 @@ describe("FTS5 Integration Tests", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skip("should rank multiple matches higher (requires FTS5 integration environment)", () => {
|
||||
// TODO: This test requires actual FTS5 database setup
|
||||
// Current test infrastructure doesn't support direct FTS5 method testing
|
||||
// Test is valid but needs integration test environment to run
|
||||
|
||||
it("should rank multiple matches higher", () => {
|
||||
cls.init(() => {
|
||||
rootNote
|
||||
.child(contentNote("Many", "keyword keyword keyword keyword"))
|
||||
.child(contentNote("Few", "keyword"));
|
||||
});
|
||||
|
||||
const searchContext = new SearchContext();
|
||||
const results = searchService.findResultsWithQuery("keyword", searchContext);
|
||||
|
||||
@ -55,7 +55,7 @@ describe('FTS5 Search Service Improvements', () => {
|
||||
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
|
||||
|
||||
// Import the service after mocking
|
||||
const module = await import('./fts_search.js');
|
||||
const module = await import('./fts/index.js');
|
||||
ftsSearchService = module.ftsSearchService;
|
||||
});
|
||||
|
||||
@ -151,15 +151,15 @@ describe('FTS5 Search Service Improvements', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect potential SQL injection attempts', () => {
|
||||
it('should allow tokens with semicolons and dashes (valid search content)', () => {
|
||||
mockSql.getValue.mockReturnValue(1);
|
||||
|
||||
// Users may search for SQL code snippets or other content containing these characters
|
||||
const query = ftsSearchService.convertToFTS5Query(['test; DROP TABLE'], '=');
|
||||
|
||||
expect(query).toContain('__invalid_token__');
|
||||
expect(mockLog.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Potential SQL injection attempt detected')
|
||||
);
|
||||
// Should preserve the content, not reject it
|
||||
expect(query).toBe('"test; DROP TABLE"');
|
||||
expect(query).not.toContain('__invalid_token__');
|
||||
});
|
||||
|
||||
it('should properly sanitize valid tokens', () => {
|
||||
@ -268,7 +268,7 @@ describe('searchWithLike - Substring Search with LIKE Queries', () => {
|
||||
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
|
||||
|
||||
// Import the service after mocking
|
||||
const module = await import('./fts_search.js');
|
||||
const module = await import('./fts/index.js');
|
||||
ftsSearchService = module.ftsSearchService;
|
||||
});
|
||||
|
||||
@ -1320,7 +1320,7 @@ describe('Exact Match with Word Boundaries (= operator)', () => {
|
||||
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
|
||||
|
||||
// Import the service after mocking
|
||||
const module = await import('./fts_search.js');
|
||||
const module = await import('./fts/index.js');
|
||||
ftsSearchService = module.ftsSearchService;
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Performance monitoring utilities for search operations
|
||||
*/
|
||||
|
||||
import log from "../log.js";
|
||||
import optionService from "../options.js";
|
||||
|
||||
export interface SearchMetrics {
|
||||
query: string;
|
||||
backend: "typescript" | "sqlite";
|
||||
totalTime: number;
|
||||
parseTime?: number;
|
||||
searchTime?: number;
|
||||
resultCount: number;
|
||||
memoryUsed?: number;
|
||||
cacheHit?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DetailedMetrics extends SearchMetrics {
|
||||
phases?: {
|
||||
name: string;
|
||||
duration: number;
|
||||
}[];
|
||||
sqliteStats?: {
|
||||
rowsScanned?: number;
|
||||
indexUsed?: boolean;
|
||||
tempBTreeUsed?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchPerformanceAverages {
|
||||
avgTime: number;
|
||||
avgResults: number;
|
||||
totalQueries: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: SearchMetrics[] = [];
|
||||
private maxMetricsStored = 1000;
|
||||
private metricsEnabled = false;
|
||||
|
||||
constructor() {
|
||||
// Check if performance logging is enabled
|
||||
this.updateSettings();
|
||||
}
|
||||
|
||||
updateSettings() {
|
||||
try {
|
||||
this.metricsEnabled = optionService.getOptionBool("searchSqlitePerformanceLogging");
|
||||
} catch {
|
||||
this.metricsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
startTimer(): () => number {
|
||||
const startTime = process.hrtime.bigint();
|
||||
return () => {
|
||||
const endTime = process.hrtime.bigint();
|
||||
return Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
recordMetrics(metrics: SearchMetrics) {
|
||||
if (!this.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.metrics.push(metrics);
|
||||
|
||||
// Keep only the last N metrics
|
||||
if (this.metrics.length > this.maxMetricsStored) {
|
||||
this.metrics = this.metrics.slice(-this.maxMetricsStored);
|
||||
}
|
||||
|
||||
// Log significant performance differences
|
||||
if (metrics.totalTime > 1000) {
|
||||
log.info(`Slow search query detected: ${metrics.totalTime.toFixed(2)}ms for query "${metrics.query.substring(0, 100)}"`);
|
||||
}
|
||||
|
||||
// Log to debug for analysis
|
||||
log.info(`Search metrics: backend=${metrics.backend}, time=${metrics.totalTime.toFixed(2)}ms, results=${metrics.resultCount}, query="${metrics.query.substring(0, 50)}"`);
|
||||
}
|
||||
|
||||
recordDetailedMetrics(metrics: DetailedMetrics) {
|
||||
if (!this.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordMetrics(metrics);
|
||||
|
||||
// Log detailed phase information
|
||||
if (metrics.phases) {
|
||||
const phaseLog = metrics.phases
|
||||
.map(p => `${p.name}=${p.duration.toFixed(2)}ms`)
|
||||
.join(", ");
|
||||
log.info(`Search phases: ${phaseLog}`);
|
||||
}
|
||||
|
||||
// Log SQLite specific stats
|
||||
if (metrics.sqliteStats) {
|
||||
log.info(`SQLite stats: rows_scanned=${metrics.sqliteStats.rowsScanned}, index_used=${metrics.sqliteStats.indexUsed}`);
|
||||
}
|
||||
}
|
||||
|
||||
getRecentMetrics(count: number = 100): SearchMetrics[] {
|
||||
return this.metrics.slice(-count);
|
||||
}
|
||||
|
||||
getAverageMetrics(backend?: "typescript" | "sqlite"): SearchPerformanceAverages | null {
|
||||
let relevantMetrics = this.metrics;
|
||||
|
||||
if (backend) {
|
||||
relevantMetrics = this.metrics.filter(m => m.backend === backend);
|
||||
}
|
||||
|
||||
if (relevantMetrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalTime = relevantMetrics.reduce((sum, m) => sum + m.totalTime, 0);
|
||||
const totalResults = relevantMetrics.reduce((sum, m) => sum + m.resultCount, 0);
|
||||
const errorCount = relevantMetrics.filter(m => m.error).length;
|
||||
|
||||
return {
|
||||
avgTime: totalTime / relevantMetrics.length,
|
||||
avgResults: totalResults / relevantMetrics.length,
|
||||
totalQueries: relevantMetrics.length,
|
||||
errorRate: errorCount / relevantMetrics.length
|
||||
};
|
||||
}
|
||||
|
||||
compareBackends(): {
|
||||
typescript: SearchPerformanceAverages;
|
||||
sqlite: SearchPerformanceAverages;
|
||||
recommendation?: string;
|
||||
} {
|
||||
const tsMetrics = this.getAverageMetrics("typescript");
|
||||
const sqliteMetrics = this.getAverageMetrics("sqlite");
|
||||
|
||||
let recommendation: string | undefined;
|
||||
|
||||
if (tsMetrics && sqliteMetrics) {
|
||||
const speedupFactor = tsMetrics.avgTime / sqliteMetrics.avgTime;
|
||||
|
||||
if (speedupFactor > 1.5) {
|
||||
recommendation = `SQLite is ${speedupFactor.toFixed(1)}x faster on average`;
|
||||
} else if (speedupFactor < 0.67) {
|
||||
recommendation = `TypeScript is ${(1/speedupFactor).toFixed(1)}x faster on average`;
|
||||
} else {
|
||||
recommendation = "Both backends perform similarly";
|
||||
}
|
||||
|
||||
// Consider error rates
|
||||
if (sqliteMetrics.errorRate > tsMetrics.errorRate + 0.1) {
|
||||
recommendation += " (but SQLite has higher error rate)";
|
||||
} else if (tsMetrics.errorRate > sqliteMetrics.errorRate + 0.1) {
|
||||
recommendation += " (but TypeScript has higher error rate)";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
typescript: tsMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
||||
sqlite: sqliteMetrics || { avgTime: 0, avgResults: 0, totalQueries: 0, errorRate: 0 },
|
||||
recommendation
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
export default performanceMonitor;
|
||||
Loading…
x
Reference in New Issue
Block a user