Wails in Practice #3 Full-Text Search and Data Flow — FTS5 and Event-Driven Updates
In #2 we stored notes in SQLite. Grow to hundreds of notes and LIKE '%term%' gets slow and imprecise. This post adds fast search with FTS5, SQLite’s built-in full-text engine, and settles the data flow — how to refresh the frontend when data changes.
This is the third of ten posts (two parts).
- #1 Designing a real project
- #2 SQLite local database
- #3 Full-text search and data flow — FTS5 and event-driven updates ← this post
- #4 Tray residence and a global shortcut — capture fast from the background
- #5 Signing and notarization — how a shipped app earns trust
- #6 CI/CD automated releases — three-platform delivery with GitHub Actions
Why FTS5 instead of LIKE #
A LIKE '%word%' search scans every row’s body from the start each time. Fine with few notes, but it slows linearly as they grow, and things like word-level matching or multi-word combinations you have to build yourself. FTS5 is SQLite’s built-in full-text extension — it splits bodies into tokens and builds an index ahead of time, so search is fast and word-based queries work directly. The pure-Go driver modernc.org/sqlite used in #2 includes FTS5, so it works with no extra setup.
Migration: an FTS5 table and sync triggers #
Append the following step to #2’s user_version migration. Create the FTS5 virtual table and add triggers so that changes to the original notes table are reflected in the search index automatically.
-- virtual table for the search index (indexing only title, body)
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, body, content='notes', content_rowid='id'
);
-- bind index changes to notes changes via triggers
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;The key is content='notes'. With it, FTS5 does not store bodies twice but references the original table (the external-content method). The three triggers (insert, delete, update) always keep the original and the index in the same state, so the repository’s CRUD code need not think about the index. The INSERT ... VALUES ('delete', ...) is FTS5’s idiom for removing an index entry.
Exposing search as a service #
Add a search method to the repository. Join the FTS5 table with the original and return matched notes newest-first.
func (r *NoteRepository) Search(query string) ([]Note, error) {
rows, err := r.db.Query(`
SELECT n.id, n.title, n.body, n.created_at, n.updated_at
FROM notes_fts f
JOIN notes n ON n.id = f.rowid
WHERE notes_fts MATCH ?
ORDER BY n.updated_at DESC
`, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNotes(rows) // reuse #2's scan logic extracted into a function
}In the service layer, filter out an empty query to fall through to the full list, and shape user input into an FTS5 query safely.
func (s *NoteService) Search(query string) ([]Note, error) {
query = strings.TrimSpace(query)
if query == "" {
return s.repo.List() // no query means the full list
}
// prefix search on the entered word: "meet" → "meet"* also matches "meeting"
return s.repo.Search(query + "*")
}This one branch — returning the full list rather than a search when the query is empty — creates the natural behavior where “clearing the search box returns to the original list” on the frontend.
Data flow: refresh the frontend with events #
Keeping the principle set in #1 — the truth of the data lives in Go — across search and editing is what data-flow design is about. Creating or deleting a note changes the list, and here there are two paths.
- The frontend fixes the list itself: it pushes the just-created note straight into the on-screen array. Fast, but risks the screen drifting from Go’s actual state.
- Go signals a change, and the frontend asks again: after a change, emit a Wails event (covered in intro #3), and the frontend takes that signal and reloads the list.
In practice the second is safer. The screen always reflects Go’s latest state, and later when #4 adds notes from outside the app via the tray or a global shortcut, one line of the same event refreshes every window.
func (a *App) CreateNote(title, body string) (Note, error) {
note, err := a.notes.Create(title, body)
if err != nil {
return Note{}, err
}
runtime.EventsEmit(a.ctx, "notes:changed") // signal that the list changed
return note, nil
}import { EventsOn } from "../wailsjs/runtime/runtime";
import { Search } from "../wailsjs/go/main/App";
EventsOn("notes:changed", async () => {
notes = await Search(currentQuery); // re-query using the current search term
});Where a snappy feel matters (an editor’s save indicator, for instance), you can compromise by changing the screen optimistically first and reverting via the event on failure. But for consistency-critical data like the list and search results, keep asking Go again as the default.
Summary #
LIKEsearch slows linearly as notes grow. Add fast word-based search with SQLite’s built-in FTS5 and its prebuilt index. The pure-Go driver includes FTS5.- FTS5 avoids duplicating bodies with
content='notes'external content and keeps the original and index in sync via insert/delete/update triggers. CRUD code need not know about the index. - In the service layer, branch an empty query to the full list, and support partial matching with prefix search (
word*). - Make “Go signals a change with an event and the frontend asks again” the default data flow. With the truth in Go, one line of an event refreshes every window even when data changes from outside the app.
- Next post layers tray residence and a global shortcut on top of this event refresh to capture notes from outside the app.