Search Engine

Search Engine

A full-text search system powered by SQLite FTS5 that enables writers to find content and glossary entries across a project using ranked results with snippet highlighting, scoped queries, and live editor match decorations.

User-Facing Behavior

Writers search their project using a search bar in the application header. Typing a query (minimum 2 characters, 300ms debounce) triggers a full-text search across all content documents and glossary entries. Results appear in a sidebar panel grouped by type (content vs glossary) with highlighted snippet excerpts. Clicking a content result navigates to that document in the editor; clicking a glossary result opens the glossary panel to that entry's detail view.

While search results are displayed, matching terms are highlighted directly in the active editor document using visual decorations (yellow background). These decorations are purely visual and never modify the document content.

Query syntax supports:

Scope

Architecture

Index Architecture

Per-Project SQLite Database
  ├── content_fts (FTS5 virtual table)
  │     Columns: content_id (UNINDEXED), title, plain_text, tags, slug_path
  └── glossary_fts (FTS5 virtual table)
        Columns: entry_id (UNINDEXED), term, definition, aliases, tags, scope_targets

Each project has its own SQLite database with two FTS5 virtual tables. Content documents are indexed with plain text extracted from ProseMirror JSON. Glossary entries are indexed with their term, definition, aliases, tags, and scope targets.

Layer Overview

Frontend (Svelte 5)
  SearchBar → searchStore → searchApi ──HTTP──> Backend (Go)
  SearchResults ← store                         SearchHandler → SearchProvider → FTS5
  ProseMirror Plugin ← store.query              QueryParser → ScopeResolver → SQLite

Incremental Indexing:
  Content CRUD ──> Repository ──> SearchProvider.IndexContent()
  Glossary CRUD ──> Repository ──> SearchProvider.IndexGlossaryEntry()

Background Jobs:
  JobManager ──> ReindexHandler ──> SearchProvider.ReindexAll()

Frontend path: User types in SearchBar (header toolbar), which debounces input and calls searchStore.search(). The store calls searchApi.searchProject() (HTTP GET), receives ranked results, and auto-opens the SearchResults panel. Simultaneously, TextEditor watches $searchStore.query via a reactive $effect and updates the searchHighlight ProseMirror plugin to render match decorations.

Backend path: SearchHandler.Search() validates query parameters, delegates to SearchProvider.Search(). The FTS5Provider parses the query via QueryParser, optionally resolves scope via ScopeResolver, executes FTS5 MATCH queries with BM25 ranking, generates highlighted snippets, and returns paginated results with facet counts.

Indexing path: Content and glossary CRUD operations in the SQLite repository call SearchProvider.IndexContent() or SearchProvider.IndexGlossaryEntry() after each mutation. These use a DELETE-then-INSERT upsert pattern to keep the FTS5 index current. On delete, RemoveFromIndex() removes the entry.

Reindex path: A search_reindex background job can be submitted to rebuild the entire index. The ReindexHandler uses callback functions (ContentLoader, GlossaryLoader) to load all indexable data, then calls ReindexAll() which drops and recreates the FTS5 tables.

Provider Pattern

SearchProvider (interface)
  ├── FTS5Provider    — SQLite FTS5 (Core license, desktop)
  └── StubProvider    — No-op fallback (graceful degradation)

The SearchProvider interface abstracts all search operations. FTS5Provider is the production implementation. StubProvider is a no-op fallback that logs a single warning and returns empty results — used when the search subsystem is unavailable. This pattern is designed to accommodate a future PostgreSQL tsvector implementation for the Pro license.

Key Design Decisions

Dataflow Diagrams

Cross-Feature Integration

Glossary Integration

Search results include glossary entries alongside content documents. When a user clicks a glossary result in the SearchResults panel, it dispatches a glossary:view-term event, which the GlossaryPanel subscribes to and navigates to the entry detail view.

The FindDocumentsContaining() method on SearchProvider is also used by the glossary system's TermScanHandler as a pre-filter to identify which content documents might contain a glossary term before performing the full mark scan.

Content Navigation

When a user clicks a content result, the SearchResults component fetches the full content object via contentApi.getContent() and dispatches a content:selected event through the moduleEventBus, which the editor page subscribes to for loading the selected document.

Security

No authentication or authorization is enforced at the search level — all operations are scoped to the local project database. The per-project SQLite isolation means one project's search index cannot query another's data. The query parser sanitizes user input for FTS5 safety (escaping special characters). No user-supplied content is executed; snippets use <mark> tags generated by FTS5's built-in snippet() function.

The snippet field in search results contains HTML (<mark> tags). The frontend renders this via {@html} in Svelte. The HTML is generated server-side by SQLite's snippet() function from indexed text, not from raw user input, so XSS risk is minimal. However, if the indexing pipeline ever stores unescaped HTML in FTS5 columns, this could become a vector.

Performance

12-Factor Compliance

The SearchProvider interface is the canonical example of 12-Factor backing service abstraction in this codebase. The StubProvider exists specifically to satisfy the principle that the app must function without any backing service being available.

Logging

Layer Component Logger Name
Backend SearchHandler "handler-search"
Backend FTS5Provider "search"
Backend QueryParser "search" (package-level)
Backend ScopeResolver "search" (package-level)
Backend ReindexHandler "search-reindex"
Frontend searchStore 'search'
Frontend SearchBar 'search-bar'
Frontend SearchResults 'search-results'
Frontend searchHighlight plugin 'search-highlight'