Compare commits

...

8 Commits

Author SHA1 Message Date
perfectra1n
09df73e125 fix(fts): remove migration statements of old 0236
Some checks failed
Checks / main (push) Has been cancelled
2025-11-28 21:36:12 -08:00
perfectra1n
f21aa321f6 fix(fts): remove index of components 2025-11-28 21:29:48 -08:00
perfectra1n
7be8b6c71e fix(fts): merge the two migrations into one file 2025-11-28 21:27:01 -08:00
perfectra1n
bb8e5ebd4a fix(fts): fix suggestions from elian 2025-11-28 21:25:24 -08:00
perfectra1n
6b8b71f7d1 feat(fts): implement missing unit tests 2025-11-28 21:12:39 -08:00
perfectra1n
191a18d7f6 feat(fts): add fts to in-memory sqlite for testing 2025-11-28 21:08:49 -08:00
perfectra1n
574a3441ee feat(fts): update imports from breaking up large fts_search file 2025-11-28 20:59:50 -08:00
perfectra1n
9940ee3bee feat(fts): break up the huge fts_search into smaller files 2025-11-28 20:57:18 -08:00
19 changed files with 1638 additions and 1768 deletions

Binary file not shown.

View File

@ -204,9 +204,6 @@ ON revisions (noteId, utcDateCreated DESC);
CREATE INDEX IDX_entity_changes_sync CREATE INDEX IDX_entity_changes_sync
ON entity_changes (isSynced, utcDateChanged); ON entity_changes (isSynced, utcDateChanged);
CREATE INDEX IDX_entity_changes_component
ON entity_changes (componentId, utcDateChanged DESC);
-- RECENT_NOTES TABLE INDEXES -- RECENT_NOTES TABLE INDEXES
CREATE INDEX IDX_recent_notes_date CREATE INDEX IDX_recent_notes_date
ON recent_notes (utcDateCreated DESC); ON recent_notes (utcDateCreated DESC);

View File

@ -17,29 +17,12 @@ import log from "../services/log.js";
export default function addFTS5SearchAndPerformanceIndexes() { export default function addFTS5SearchAndPerformanceIndexes() {
log.info("Starting FTS5 and performance optimization migration..."); 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 // Part 1: FTS5 Setup
log.info("Creating FTS5 virtual table for full-text search..."); log.info("Creating FTS5 virtual table for full-text search...");
// Create FTS5 virtual table // Create FTS5 virtual table
// We store noteId, title, and content for searching // We store noteId, title, and content for searching
sql.executeScript(` 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 -- Create FTS5 virtual table with trigram tokenizer
-- Trigram tokenizer provides language-agnostic substring matching: -- Trigram tokenizer provides language-agnostic substring matching:
-- 1. Fast substring matching (50-100x speedup for LIKE queries without wildcards) -- 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"); 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 // RECENT_NOTES TABLE INDEXES
// ======================================== // ========================================
@ -648,5 +622,22 @@ export default function addFTS5SearchAndPerformanceIndexes() {
log.info("Attributes FTS5 setup completed successfully"); 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"); log.info("FTS5 and performance optimization migration completed successfully");
} }

View File

@ -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}`);
}
}

View File

@ -6,11 +6,6 @@
// Migrations should be kept in descending order, so the latest migration is first. // Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [ 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 // Add FTS5 full-text search support and strategic performance indexes
{ {
version: 234, version: 234,

View File

@ -10,7 +10,7 @@ import cls from "../../services/cls.js";
import attributeFormatter from "../../services/attribute_formatter.js"; import attributeFormatter from "../../services/attribute_formatter.js";
import ValidationError from "../../errors/validation_error.js"; import ValidationError from "../../errors/validation_error.js";
import type SearchResult from "../../services/search/search_result.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 log from "../../services/log.js";
import hoistedNoteService from "../../services/hoisted_note.js"; import hoistedNoteService from "../../services/hoisted_note.js";
import beccaService from "../../becca/becca_service.js"; import beccaService from "../../becca/becca_service.js";

View File

@ -11,7 +11,7 @@ import auth from "../services/auth.js";
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js"; import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
import { safeExtractMessageAndStackFromError } from "../services/utils.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(); export const router = express.Router();
// TODO: Deduplicate with etapi_utils.ts afterwards. // TODO: Deduplicate with etapi_utils.ts afterwards.
@ -183,7 +183,7 @@ export function createUploadMiddleware(): RequestHandler {
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) { if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
multerOptions.limits = { multerOptions.limits = {
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024 * 1024 fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
}; };
} }

View File

@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
import dataDir from "./data_dir.js"; import dataDir from "./data_dir.js";
import { AppInfo } from "@triliumnext/commons"; import { AppInfo } from "@triliumnext/commons";
const APP_DB_VERSION = 236; const APP_DB_VERSION = 234;
const SYNC_VERSION = 36; const SYNC_VERSION = 36;
const CLIPPER_PROTOCOL_VERSION = "1.0"; const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@ -20,7 +20,7 @@ import {
getRegex, getRegex,
FUZZY_SEARCH_CONFIG FUZZY_SEARCH_CONFIG
} from "../utils/text_utils.js"; } 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(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]); const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);

View 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';
}
}

View 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;

View 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;
}

View 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) + '...';
}

View 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 [];
}
}

View 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;

View File

@ -14,12 +14,14 @@
*/ */
import { describe, it, expect, beforeEach, vi } from "vitest"; 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 searchService from "./services/search.js";
import BNote from "../../becca/entities/bnote.js"; import BNote from "../../becca/entities/bnote.js";
import BBranch from "../../becca/entities/bbranch.js"; import BBranch from "../../becca/entities/bbranch.js";
import SearchContext from "./search_context.js"; import SearchContext from "./search_context.js";
import becca from "../../becca/becca.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 { note, NoteBuilder } from "../../test/becca_mocking.js";
import { import {
searchNote, searchNote,
@ -52,40 +54,37 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("FTS5 Availability", () => { describe("FTS5 Availability", () => {
it.skip("should detect FTS5 availability (requires FTS5 integration test setup)", () => { it("should detect FTS5 availability", () => {
// 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
const isAvailable = ftsSearchService.checkFTS5Availability(); const isAvailable = ftsSearchService.checkFTS5Availability();
expect(typeof isAvailable).toBe("boolean"); expect(typeof isAvailable).toBe("boolean");
}); });
it.skip("should cache FTS5 availability check (requires FTS5 integration test setup)", () => { it("should cache FTS5 availability check", () => {
// 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
const first = ftsSearchService.checkFTS5Availability(); const first = ftsSearchService.checkFTS5Availability();
const second = ftsSearchService.checkFTS5Availability(); const second = ftsSearchService.checkFTS5Availability();
expect(first).toBe(second); expect(first).toBe(second);
}); });
it.todo("should provide meaningful error when FTS5 not available", () => { it("should provide meaningful error when FTS5 not available", () => {
// This test would need to mock sql.getValue to simulate FTS5 unavailability // Test that assertFTS5Available throws a meaningful error when FTS5 table is missing
// Implementation depends on actual mocking strategy // We can't actually remove the table, but we can test the error class behavior
expect(true).toBe(true); // Placeholder 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", () => { describe("Query Execution", () => {
it.skip("should execute basic exact match query (requires FTS5 integration environment)", () => { it("should execute basic exact match query", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Document One", "This contains the search term.")) .child(contentNote("Document One", "This contains the search term."))
.child(contentNote("Document Two", "Another search term here.")) .child(contentNote("Document Two", "Another search term here."))
.child(contentNote("Different", "No matching words.")); .child(contentNote("Different", "No matching words."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("search term", searchContext); const results = searchService.findResultsWithQuery("search term", searchContext);
@ -97,15 +96,13 @@ describe("FTS5 Integration Tests", () => {
.doesNotHaveTitle("Different"); .doesNotHaveTitle("Different");
}); });
it.skip("should handle multiple tokens with AND logic (requires FTS5 integration environment)", () => { it("should handle multiple tokens with AND logic", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Both", "Contains search and term together.")) .child(contentNote("Both", "Contains search and term together."))
.child(contentNote("Only Search", "Contains search only.")) .child(contentNote("Only Search", "Contains search only."))
.child(contentNote("Only Term", "Contains term only.")); .child(contentNote("Only Term", "Contains term only."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("search term", searchContext); const results = searchService.findResultsWithQuery("search term", searchContext);
@ -114,18 +111,17 @@ describe("FTS5 Integration Tests", () => {
assertContainsTitle(results, "Both"); assertContainsTitle(results, "Both");
}); });
it.skip("should support OR operator (requires FTS5 integration environment)", () => { it("should support OR operator", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("First", "Contains alpha.")) .child(contentNote("First", "Contains alpha."))
.child(contentNote("Second", "Contains beta.")) .child(contentNote("Second", "Contains beta."))
.child(contentNote("Neither", "Contains gamma.")); .child(contentNote("Neither", "Contains gamma."));
});
const searchContext = new SearchContext(); 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) expectResults(results)
.hasMinCount(2) .hasMinCount(2)
@ -134,15 +130,13 @@ describe("FTS5 Integration Tests", () => {
.doesNotHaveTitle("Neither"); .doesNotHaveTitle("Neither");
}); });
it.skip("should support NOT operator (requires FTS5 integration environment)", () => { it("should support NOT operator", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Included", "Contains positive but not negative.")) .child(contentNote("Included", "Contains positive but not negative."))
.child(contentNote("Excluded", "Contains positive and negative.")) .child(contentNote("Excluded", "Contains positive and negative."))
.child(contentNote("Neither", "Contains neither.")); .child(contentNote("Neither", "Contains neither."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("positive NOT negative", searchContext); const results = searchService.findResultsWithQuery("positive NOT negative", searchContext);
@ -153,14 +147,12 @@ describe("FTS5 Integration Tests", () => {
.doesNotHaveTitle("Excluded"); .doesNotHaveTitle("Excluded");
}); });
it.skip("should handle phrase search with quotes (requires FTS5 integration environment)", () => { it("should handle phrase search with quotes", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Exact", 'Contains "exact phrase" in order.')) .child(contentNote("Exact", 'Contains "exact phrase" in order.'))
.child(contentNote("Scrambled", "Contains phrase exact in wrong order.")); .child(contentNote("Scrambled", "Contains phrase exact in wrong order."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery('"exact phrase"', searchContext); const results = searchService.findResultsWithQuery('"exact phrase"', searchContext);
@ -171,14 +163,12 @@ describe("FTS5 Integration Tests", () => {
.doesNotHaveTitle("Scrambled"); .doesNotHaveTitle("Scrambled");
}); });
it.skip("should enforce minimum token length of 3 characters (requires FTS5 integration environment)", () => { it("should enforce minimum token length of 3 characters", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Short", "Contains ab and xy tokens.")) .child(contentNote("Short", "Contains ab and xy tokens."))
.child(contentNote("Long", "Contains abc and xyz tokens.")); .child(contentNote("Long", "Contains abc and xyz tokens."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -194,14 +184,12 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Content Size Limits", () => { describe("Content Size Limits", () => {
it.skip("should handle notes up to 10MB content size (requires FTS5 integration environment)", () => { it("should handle notes up to 10MB content size", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
// Create a note with large content (but less than 10MB) // Create a note with large content (but less than 10MB)
const largeContent = "test ".repeat(100000); // ~500KB const largeContent = "test ".repeat(100000); // ~500KB
rootNote.child(contentNote("Large Note", largeContent)); rootNote.child(contentNote("Large Note", largeContent));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("test", searchContext); const results = searchService.findResultsWithQuery("test", searchContext);
@ -209,16 +197,14 @@ describe("FTS5 Integration Tests", () => {
expectResults(results).hasMinCount(1).hasTitle("Large Note"); expectResults(results).hasMinCount(1).hasTitle("Large Note");
}); });
it.skip("should still find notes exceeding 10MB by title (requires FTS5 integration environment)", () => { it("should still find notes exceeding 10MB by title", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
// Create a note with very large content (simulate >10MB) // Create a note with very large content (simulate >10MB)
const veryLargeContent = "x".repeat(11 * 1024 * 1024); // 11MB const veryLargeContent = "x".repeat(11 * 1024 * 1024); // 11MB
const largeNote = searchNote("Oversized Note"); const largeNote = searchNote("Oversized Note");
largeNote.content(veryLargeContent); largeNote.content(veryLargeContent);
rootNote.child(largeNote); rootNote.child(largeNote);
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -227,12 +213,10 @@ describe("FTS5 Integration Tests", () => {
expectResults(results).hasMinCount(1).hasTitle("Oversized Note"); expectResults(results).hasMinCount(1).hasTitle("Oversized Note");
}); });
it.skip("should handle empty content gracefully (requires FTS5 integration environment)", () => { it("should handle empty content gracefully", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Empty Note", "")); rootNote.child(contentNote("Empty Note", ""));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("Empty", searchContext); const results = searchService.findResultsWithQuery("Empty", searchContext);
@ -242,14 +226,26 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Protected Notes Handling", () => { describe("Protected Notes Handling", () => {
it.skip("should not index protected notes in FTS5 (requires FTS5 integration environment)", () => { it("should not index protected notes in FTS5", () => {
// TODO: This test requires actual FTS5 database setup // Protected notes require an active protected session to set content
// Current test infrastructure doesn't support direct FTS5 method testing // We test with a note marked as protected but without content
// Test is valid but needs integration test environment to run cls.init(() => {
rootNote rootNote
.child(contentNote("Public", "This is public content.")) .child(contentNote("Public", "This is public content."));
.child(protectedNote("Secret", "This is secret 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 searchContext = new SearchContext({ includeArchivedNotes: false });
const results = searchService.findResultsWithQuery("content", searchContext); const results = searchService.findResultsWithQuery("content", searchContext);
@ -258,25 +254,39 @@ describe("FTS5 Integration Tests", () => {
assertNoProtectedNotes(results); assertNoProtectedNotes(results);
}); });
it.todo("should search protected notes separately when session available", () => { it("should search protected notes separately when session available", () => {
const publicNote = contentNote("Public", "Contains keyword."); // Test that the searchProtectedNotesSync function exists and returns empty
const secretNote = protectedNote("Secret", "Contains keyword."); // when no protected session is available (which is the case in tests)
const results = ftsSearchService.searchProtectedNotesSync(
["test"],
"*=*",
undefined,
{}
);
rootNote.child(publicNote).child(secretNote); // Without an active protected session, should return empty array
expect(results).toEqual([]);
// This would require mocking protectedSessionService
// to simulate an active protected session
expect(true).toBe(true); // Placeholder for actual test
}); });
it.skip("should exclude protected notes from results by default (requires FTS5 integration environment)", () => { it("should exclude protected notes from results by default", () => {
// TODO: This test requires actual FTS5 database setup // Test that protected notes (by isProtected flag) are excluded
// Current test infrastructure doesn't support direct FTS5 method testing cls.init(() => {
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Normal", "Regular content.")) .child(contentNote("Normal", "Regular content."));
.child(protectedNote("Protected", "Protected 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 searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("content", searchContext); const results = searchService.findResultsWithQuery("content", searchContext);
@ -286,28 +296,24 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Query Syntax Conversion", () => { describe("Query Syntax Conversion", () => {
it.skip("should convert exact match operator (=) (requires FTS5 integration environment)", () => { it("should convert exact match operator (=)", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Test", "This is a test document.")); rootNote.child(contentNote("Test", "This is a test document."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
// Search with fulltext operator (FTS5 searches content by default) // Search with content contains operator
const results = searchService.findResultsWithQuery('note *=* test', searchContext); const results = searchService.findResultsWithQuery('note.content *=* test', searchContext);
expectResults(results).hasMinCount(1); expectResults(results).hasMinCount(1);
}); });
it.skip("should convert contains operator (*=*) (requires FTS5 integration environment)", () => { it("should convert contains operator (*=*)", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Match", "Contains search keyword.")) .child(contentNote("Match", "Contains search keyword."))
.child(contentNote("No Match", "Different content.")); .child(contentNote("No Match", "Different content."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("note.content *=* search", searchContext); const results = searchService.findResultsWithQuery("note.content *=* search", searchContext);
@ -317,14 +323,12 @@ describe("FTS5 Integration Tests", () => {
.hasTitle("Match"); .hasTitle("Match");
}); });
it.skip("should convert starts-with operator (=*) (requires FTS5 integration environment)", () => { it("should convert starts-with operator (=*)", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Starts", "Testing starts with keyword.")) .child(contentNote("Starts", "Testing starts with keyword."))
.child(contentNote("Ends", "Keyword at the end Testing.")); .child(contentNote("Ends", "Keyword at the end Testing."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("note.content =* Testing", searchContext); const results = searchService.findResultsWithQuery("note.content =* Testing", searchContext);
@ -334,14 +338,12 @@ describe("FTS5 Integration Tests", () => {
.hasTitle("Starts"); .hasTitle("Starts");
}); });
it.skip("should convert ends-with operator (*=) (requires FTS5 integration environment)", () => { it("should convert ends-with operator (*=)", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Ends", "Content ends with Testing")) .child(contentNote("Ends", "Content ends with Testing"))
.child(contentNote("Starts", "Testing starts here")); .child(contentNote("Starts", "Testing starts here"));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("note.content *= Testing", searchContext); const results = searchService.findResultsWithQuery("note.content *= Testing", searchContext);
@ -351,30 +353,28 @@ describe("FTS5 Integration Tests", () => {
.hasTitle("Ends"); .hasTitle("Ends");
}); });
it.skip("should handle not-equals operator (!=) (requires FTS5 integration environment)", () => { it("should handle not-equals operator (!=)", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Includes", "Contains excluded term.")) .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 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"); assertContainsTitle(results, "Clean");
}); });
}); });
describe("Token Sanitization", () => { describe("Token Sanitization", () => {
it.skip("should sanitize tokens with special FTS5 characters (requires FTS5 integration environment)", () => { it("should sanitize tokens with special FTS5 characters", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Test", "Contains special (characters) here.")); rootNote.child(contentNote("Test", "Contains special (characters) here."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("special (characters)", searchContext); const results = searchService.findResultsWithQuery("special (characters)", searchContext);
@ -383,12 +383,10 @@ describe("FTS5 Integration Tests", () => {
expectResults(results).hasMinCount(1); expectResults(results).hasMinCount(1);
}); });
it.skip("should handle tokens with quotes (requires FTS5 integration environment)", () => { it("should handle tokens with quotes", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Quotes", 'Contains "quoted text" here.')); rootNote.child(contentNote("Quotes", 'Contains "quoted text" here.'));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery('"quoted text"', searchContext); const results = searchService.findResultsWithQuery('"quoted text"', searchContext);
@ -396,12 +394,10 @@ describe("FTS5 Integration Tests", () => {
expectResults(results).hasMinCount(1).hasTitle("Quotes"); expectResults(results).hasMinCount(1).hasTitle("Quotes");
}); });
it.skip("should prevent SQL injection attempts (requires FTS5 integration environment)", () => { it("should prevent SQL injection attempts", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Safe", "Normal content.")); rootNote.child(contentNote("Safe", "Normal content."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -414,11 +410,7 @@ describe("FTS5 Integration Tests", () => {
expect(Array.isArray(results)).toBe(true); expect(Array.isArray(results)).toBe(true);
}); });
it.skip("should handle empty tokens after sanitization (requires FTS5 integration environment)", () => { it("should handle empty tokens after sanitization", () => {
// 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 searchContext = new SearchContext(); const searchContext = new SearchContext();
// Token with only special characters // Token with only special characters
@ -430,11 +422,8 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Snippet Extraction", () => { describe("Snippet Extraction", () => {
it.skip("should extract snippets from matching content (requires FTS5 integration environment)", () => { it("should extract snippets from matching content", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
const longContent = ` const longContent = `
This is a long document with many paragraphs. This is a long document with many paragraphs.
The keyword appears here in the middle of the text. The keyword appears here in the middle of the text.
@ -443,36 +432,27 @@ describe("FTS5 Integration Tests", () => {
`; `;
rootNote.child(contentNote("Long Document", longContent)); rootNote.child(contentNote("Long Document", longContent));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("keyword", searchContext); const results = searchService.findResultsWithQuery("keyword", searchContext);
expectResults(results).hasMinCount(1); 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)", () => { it("should highlight matched terms in snippets", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Highlight Test", "This contains the search term to highlight.")); rootNote.child(contentNote("Highlight Test", "This contains the search term to highlight."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("search", searchContext); const results = searchService.findResultsWithQuery("search", searchContext);
expectResults(results).hasMinCount(1); 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)", () => { it("should extract multiple snippets for multiple matches", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
const content = ` const content = `
First occurrence of keyword here. First occurrence of keyword here.
Some other content in between. Some other content in between.
@ -482,41 +462,34 @@ describe("FTS5 Integration Tests", () => {
`; `;
rootNote.child(contentNote("Multiple Matches", content)); rootNote.child(contentNote("Multiple Matches", content));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("keyword", searchContext); const results = searchService.findResultsWithQuery("keyword", searchContext);
expectResults(results).hasMinCount(1); expectResults(results).hasMinCount(1);
// Should have multiple snippets or combined snippet
}); });
it.skip("should respect snippet length limits (requires FTS5 integration environment)", () => { it("should respect snippet length limits", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
const veryLongContent = "word ".repeat(10000) + "target " + "word ".repeat(10000); const veryLongContent = "word ".repeat(10000) + "target " + "word ".repeat(10000);
rootNote.child(contentNote("Very Long", veryLongContent)); rootNote.child(contentNote("Very Long", veryLongContent));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("target", searchContext); const results = searchService.findResultsWithQuery("target", searchContext);
expectResults(results).hasMinCount(1); expectResults(results).hasMinCount(1);
// Snippet should not include entire document
}); });
}); });
describe("Chunking for Large Content", () => { describe("Chunking for Large Content", () => {
it.skip("should chunk content exceeding size limits (requires FTS5 integration environment)", () => { it("should chunk content exceeding size limits", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
// Create content that would need chunking // Create content that would need chunking
const chunkContent = "searchable ".repeat(5000); // Large repeated content const chunkContent = "searchable ".repeat(5000); // Large repeated content
rootNote.child(contentNote("Chunked", chunkContent)); rootNote.child(contentNote("Chunked", chunkContent));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("searchable", searchContext); const results = searchService.findResultsWithQuery("searchable", searchContext);
@ -524,17 +497,15 @@ describe("FTS5 Integration Tests", () => {
expectResults(results).hasMinCount(1).hasTitle("Chunked"); expectResults(results).hasMinCount(1).hasTitle("Chunked");
}); });
it.skip("should search across all chunks (requires FTS5 integration environment)", () => { it("should search across all chunks", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
// Create content where matches appear in different "chunks" // Create content where matches appear in different "chunks"
const part1 = "alpha ".repeat(1000); const part1 = "alpha ".repeat(1000);
const part2 = "beta ".repeat(1000); const part2 = "beta ".repeat(1000);
const combined = part1 + part2; const combined = part1 + part2;
rootNote.child(contentNote("Multi-Chunk", combined)); rootNote.child(contentNote("Multi-Chunk", combined));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -548,12 +519,10 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Error Handling and Recovery", () => { describe("Error Handling and Recovery", () => {
it.skip("should handle malformed queries gracefully (requires FTS5 integration environment)", () => { it("should handle malformed queries gracefully", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Test", "Normal content.")); rootNote.child(contentNote("Test", "Normal content."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -564,17 +533,35 @@ describe("FTS5 Integration Tests", () => {
expect(Array.isArray(results)).toBe(true); expect(Array.isArray(results)).toBe(true);
}); });
it.todo("should provide meaningful error messages", () => { it("should provide meaningful error messages", () => {
// This would test FTSError classes and error recovery // Test FTSQueryError provides meaningful information
expect(true).toBe(true); // Placeholder 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)", () => { it("should fall back to non-FTS search on FTS errors", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote.child(contentNote("Fallback", "Content for fallback test.")); rootNote.child(contentNote("Fallback", "Content for fallback test."));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
@ -586,15 +573,7 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Index Management", () => { describe("Index Management", () => {
it.skip("should provide index statistics (requires FTS5 integration test setup)", () => { it("should provide index statistics", () => {
// 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"));
// Get FTS index stats // Get FTS index stats
const stats = ftsSearchService.getIndexStats(); const stats = ftsSearchService.getIndexStats();
@ -602,39 +581,61 @@ describe("FTS5 Integration Tests", () => {
expect(stats.totalDocuments).toBeGreaterThan(0); expect(stats.totalDocuments).toBeGreaterThan(0);
}); });
it.todo("should handle index optimization", () => { it("should handle index optimization", () => {
rootNote.child(contentNote("Before Optimize", "Content to index.")); // 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 // Run the optimize command directly via SQL
// FTS5 manages optimization internally via the 'optimize' command // This is what rebuildIndex() does internally
// This test should either call the internal FTS5 optimize directly expect(() => {
// or test the syncMissingNotes() method which triggers optimization 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 searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("index", searchContext); const results = searchService.findResultsWithQuery("optimize", searchContext);
expectResults(results).hasMinCount(1); expectResults(results).hasMinCount(1);
}); });
it.todo("should detect when index needs rebuilding", () => { it("should detect when index needs rebuilding", () => {
// Note: needsIndexRebuild() method doesn't exist in ftsSearchService // Test syncMissingNotes which detects and fixes missing index entries
// This test should be implemented when the method is added to the service cls.init(() => {
// For now, we can test syncMissingNotes() which serves a similar purpose // Create a note that will be in becca but may not be in FTS
expect(true).toBe(true); 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", () => { describe("Performance and Limits", () => {
it.skip("should handle large result sets efficiently (requires FTS5 integration environment)", () => { it("should handle large result sets efficiently", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
// Create many matching notes // Create many matching notes
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
rootNote.child(contentNote(`Document ${i}`, `Contains searchterm in document ${i}.`)); rootNote.child(contentNote(`Document ${i}`, `Contains searchterm in document ${i}.`));
} }
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const startTime = Date.now(); const startTime = Date.now();
@ -649,11 +650,7 @@ describe("FTS5 Integration Tests", () => {
expect(duration).toBeLessThan(1000); expect(duration).toBeLessThan(1000);
}); });
it.skip("should respect query length limits (requires FTS5 integration environment)", () => { it("should respect query length limits", () => {
// 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 searchContext = new SearchContext(); const searchContext = new SearchContext();
// Very long query should be handled // Very long query should be handled
@ -663,14 +660,12 @@ describe("FTS5 Integration Tests", () => {
expect(results).toBeDefined(); expect(results).toBeDefined();
}); });
it.skip("should apply limit to results (requires FTS5 integration environment)", () => { it("should apply limit to results", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
rootNote.child(contentNote(`Note ${i}`, "matching content")); rootNote.child(contentNote(`Note ${i}`, "matching content"));
} }
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("matching limit 10", searchContext); const results = searchService.findResultsWithQuery("matching limit 10", searchContext);
@ -680,14 +675,12 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Integration with Search Context", () => { describe("Integration with Search Context", () => {
it.skip("should respect fast search flag (requires FTS5 integration environment)", () => { it("should respect fast search flag", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Title Match", "Different content")) .child(contentNote("Title Match", "Different content"))
.child(contentNote("Different Title", "Matching content")); .child(contentNote("Different Title", "Matching content"));
});
const fastContext = new SearchContext({ fastSearch: true }); const fastContext = new SearchContext({ fastSearch: true });
const results = searchService.findResultsWithQuery("content", fastContext); const results = searchService.findResultsWithQuery("content", fastContext);
@ -696,15 +689,13 @@ describe("FTS5 Integration Tests", () => {
expect(results).toBeDefined(); expect(results).toBeDefined();
}); });
it.skip("should respect includeArchivedNotes flag (requires FTS5 integration environment)", () => { it("should respect includeArchivedNotes flag", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
const archived = searchNote("Archived").label("archived", "", true); const archived = searchNote("Archived").label("archived", "", true);
archived.content("Archived content"); archived.content("Archived content");
rootNote.child(archived); rootNote.child(archived);
});
// Without archived flag // Without archived flag
const normalContext = new SearchContext({ includeArchivedNotes: false }); const normalContext = new SearchContext({ includeArchivedNotes: false });
@ -718,11 +709,8 @@ describe("FTS5 Integration Tests", () => {
expect(results2.length).toBeGreaterThanOrEqual(results1.length); expect(results2.length).toBeGreaterThanOrEqual(results1.length);
}); });
it.skip("should respect ancestor filtering (requires FTS5 integration environment)", () => { it("should respect ancestor filtering", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
const europe = searchNote("Europe"); const europe = searchNote("Europe");
const austria = contentNote("Austria", "European country"); const austria = contentNote("Austria", "European country");
const asia = searchNote("Asia"); const asia = searchNote("Asia");
@ -730,24 +718,26 @@ describe("FTS5 Integration Tests", () => {
rootNote.child(europe.child(austria)); rootNote.child(europe.child(austria));
rootNote.child(asia.child(japan)); 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); const results = searchService.findResultsWithQuery("country", searchContext);
// Should only find notes under Europe // Should only find notes under Europe
expectResults(results) expectResults(results)
.hasTitle("Austria") .hasTitle("Austria")
.doesNotHaveTitle("Japan"); .doesNotHaveTitle("Japan");
}
}); });
}); });
describe("Complex Search Fixtures", () => { describe("Complex Search Fixtures", () => {
it.skip("should work with full text search fixture (requires FTS5 integration environment)", () => { it("should work with full text search fixture", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing createFullTextSearchFixture(rootNote);
// Test is valid but needs integration test environment to run });
const fixture = createFullTextSearchFixture(rootNote);
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("search", searchContext); const results = searchService.findResultsWithQuery("search", searchContext);
@ -758,14 +748,12 @@ describe("FTS5 Integration Tests", () => {
}); });
describe("Result Quality", () => { describe("Result Quality", () => {
it.skip("should not return duplicate results (requires FTS5 integration environment)", () => { it("should not return duplicate results", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Duplicate Test", "keyword keyword keyword")) .child(contentNote("Duplicate Test", "keyword keyword keyword"))
.child(contentNote("Another", "keyword")); .child(contentNote("Another", "keyword"));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("keyword", searchContext); const results = searchService.findResultsWithQuery("keyword", searchContext);
@ -773,14 +761,12 @@ describe("FTS5 Integration Tests", () => {
assertNoDuplicates(results); assertNoDuplicates(results);
}); });
it.skip("should rank exact title matches higher (requires FTS5 integration environment)", () => { it("should rank exact title matches higher", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Exact", "Other content")) .child(contentNote("Exact", "Other content"))
.child(contentNote("Different", "Contains Exact in content")); .child(contentNote("Different", "Contains Exact in content"));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("Exact", 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)", () => { it("should rank multiple matches higher", () => {
// TODO: This test requires actual FTS5 database setup cls.init(() => {
// Current test infrastructure doesn't support direct FTS5 method testing
// Test is valid but needs integration test environment to run
rootNote rootNote
.child(contentNote("Many", "keyword keyword keyword keyword")) .child(contentNote("Many", "keyword keyword keyword keyword"))
.child(contentNote("Few", "keyword")); .child(contentNote("Few", "keyword"));
});
const searchContext = new SearchContext(); const searchContext = new SearchContext();
const results = searchService.findResultsWithQuery("keyword", searchContext); const results = searchService.findResultsWithQuery("keyword", searchContext);

View File

@ -55,7 +55,7 @@ describe('FTS5 Search Service Improvements', () => {
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession })); vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
// Import the service after mocking // Import the service after mocking
const module = await import('./fts_search.js'); const module = await import('./fts/index.js');
ftsSearchService = module.ftsSearchService; 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); mockSql.getValue.mockReturnValue(1);
// Users may search for SQL code snippets or other content containing these characters
const query = ftsSearchService.convertToFTS5Query(['test; DROP TABLE'], '='); const query = ftsSearchService.convertToFTS5Query(['test; DROP TABLE'], '=');
expect(query).toContain('__invalid_token__'); // Should preserve the content, not reject it
expect(mockLog.error).toHaveBeenCalledWith( expect(query).toBe('"test; DROP TABLE"');
expect.stringContaining('Potential SQL injection attempt detected') expect(query).not.toContain('__invalid_token__');
);
}); });
it('should properly sanitize valid tokens', () => { it('should properly sanitize valid tokens', () => {
@ -268,7 +268,7 @@ describe('searchWithLike - Substring Search with LIKE Queries', () => {
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession })); vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
// Import the service after mocking // Import the service after mocking
const module = await import('./fts_search.js'); const module = await import('./fts/index.js');
ftsSearchService = module.ftsSearchService; ftsSearchService = module.ftsSearchService;
}); });
@ -1320,7 +1320,7 @@ describe('Exact Match with Word Boundaries (= operator)', () => {
vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession })); vi.doMock('../protected_session.js', () => ({ default: mockProtectedSession }));
// Import the service after mocking // Import the service after mocking
const module = await import('./fts_search.js'); const module = await import('./fts/index.js');
ftsSearchService = module.ftsSearchService; ftsSearchService = module.ftsSearchService;
}); });

File diff suppressed because it is too large Load Diff

View File

@ -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;