Search — full-text search with SQLite FTS5
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
Fast free-text search in your collections, with ranking (bm25) and snippets where the match is highlighted. The search respects the collection's own rules: nobody may see a match that they would not have been allowed to see through PocketBase's regular list API.
Tested against PocketBase 0.40.4 (SQLite 3.53.3, built with ENABLE_FTS5).
#Installation
Copy pb_hooks/ and pb_migrations/ to your PocketBase and restart. The collection zk_sok_installningar appears in the admin panel. The index itself lives in two regular SQLite tables (zk_sok_poster, zk_sok_index) that are not visible in the admin panel.
#Choose what should be searchable
Create one row in zk_sok_installningar per collection (superuser only):
| Field | Example | |
|---|---|---|
samling | artiklar | The collection's name |
falt | ["titel", "innehall"] | Fields of type text or editor (HTML tags are removed before indexing) |
aktiv | on | Off = the collection is not searchable |
The index is built as soon as the row is saved, and is then kept in sync automatically when records are created, changed and deleted. Hidden fields cannot be selected — the snippets would otherwise show them. If a field is made hidden afterwards, it stops being searched immediately.
Rebuild the whole index (e.g. after importing directly into the database):
POST /api/zk/sok/bygg-om (superuser) all collections
POST /api/zk/sok/bygg-om?samling=x (superuser) one collection
#Searching from the app
const res = await pb.send("/api/zk/sok", { query: { q: "grafikkort", samling: "artiklar", antal: 20, sida: 1 } })
// res = { traffar: [...], mer: true|false, sida, antal }
for (const t of res.traffar) {
t.samling // "artiklar"
t.id // the record's id
t.falt // the field that matched best
t.utdrag // "…om <mark>grafikkort</mark> här…" — HTML-safe, see below
t.poang // higher = better match
t.post // the whole record, exactly as the list API would have shown it
}
samlingis optional (comma-separated list). Without it, all configured collections are searched.antal1–50 (default 20),sida1–50.mertells you whether there are more matches.- Authentication is sent as usual by the SDK (
pb.authStore).
#The query language
- Words are separated by spaces; all words must be present (
åsa bilen). bil*= prefix search (bilen, bilar, bilverkstad). Requires at least two characters before*.- Everything else is plain text.
",:,(,),^,{},-,+become word boundaries, andOR,NOT,NEARare just words. It is not possible to write FTS5 syntax through the search box — on purpose. - At most 10 words, 300 characters.
#Swedish
The tokenizer is unicode61 remove_diacritics 0. The default choice in many guides (remove_diacritics 1/2) folds å/ä/ö to a/o — then "får" also finds "far" and "hål" also finds "hal". Here it does not. (In Swedish, å, ä and ö are separate letters, not accented variants, so these are different words: "får" = sheep/gets, "far" = father; "hål" = hole, "hal" = slippery; "kör" = choir/drives, "kor" = cows.)
| Search | Finds | Does not find |
|---|---|---|
får | får | far |
hål | hål | hal |
kör | kör | kor |
åsa, ÅSA | Åsa |
Case is folded (including Å/å). The price: someone who searches ole does not find "Olé" — accented characters must be typed as they are in the text. For Swedish text that is the right trade-off.
#Security
- Permission per record. FTS5 is only used to produce candidates. Each candidate is fetched as a real record and checked against the collection's
listRulewith the caller's authentication ($app.canAccessRecord). Collection withlistRule= null (locked) → superuser only. Tested: user A never sees B's matches, a guest only sees what a guest may list, and the result is exactly the same as PocketBase's list API gives the same caller. - No counters that leak. The response contains no total count, only the records you may see and
mer. Tested: A's response is identical whether B has 0 or 250 matches. - The snippets are HTML-safe. All text is escaped; only the plugin's own
<mark>tags remain. You can displayt.utdragwithinnerHTML. - The record goes through PocketBase's regular enrichment (
$apis.enrichRecords): hidden fields are removed exactly as in the list API (tested). Email visibility andexpandare handled by the same PocketBase function but are not separately tested here. - The query is sanitised into quoted words before it reaches
MATCH— tested with quotation marks, colons, parentheses, NEAR/OR/NOT, SQL attempts, null bytes, 5000 characters and emoji.
If you want to limit how often someone may search, turn on PocketBase's built-in rate limit (Settings → Rate limiting) with a rule for /api/zk/sok.
#Limitations
- A search checks at most 1000 index rows per call. If a caller has very many matches they are not allowed to see ranked ahead of their own (e.g. thousands of other users' records with the same word), their own may be missed. The limit protects the server; permissions still hold.
- Timing differences can reveal a little. A search where many candidates are checked and rejected takes longer than one where there are none. The response leaks nothing, but the response time does in theory. If you need to protect against that, use separate collections per customer instead.
poang(bm25) depends on the whole index, including records you do not see. The number can therefore change when others write. Use it for ordering, not as an absolute measure.- Rules at record level, not field level. Anyone allowed to list a record sees snippets from all indexed fields in it — only choose fields that are okay to show to everyone allowed to list the record.
- If a collection is renamed, the settings row must be updated to the new name (the index is then rebuilt). If the collection is deleted, the index and the setting are cleaned up automatically.
- Changes that bypass PocketBase (raw SQL against the database) do not show up in the index until you run
bygg-om. - Only fields of type text and editor. No stemming (bil ≠ bilar without
*), no synonyms, no spelling tolerance. - The index is rebuilt in the same call in which the setting is saved — for very large collections (hundreds of thousands of records) this takes time. Not performance-measured at a larger scale than the tests (a few hundred records).
#Testing
./bank.py sok
14 tests: Swedish characters (får/far, hål/hal, kör/kor, Åsa/åsa, prefix), ranking, snippets (HTML-safe, editor fields cleaned), owner rule (A/B/guest, compared against PocketBase's own list API), that the response does not leak others' matches, locked and public collections, hidden fields, injection into the query language, sync on change/deletion, failed batch, validation of settings, rebuild (including with orphaned index rows), deleted collection, pages. The security protections are mutant-verified: every protection has been deliberately broken and caught by at least one test.