generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 3s
Slice query-surface, part 2 (tech-spec §6). Migration v2 adds an FTS5 external-content table over nodes(title, body), kept in sync by insert/update/delete triggers (with a backfill for existing rows). - Store::search(query): owner-scoped, tombstones excluded, best-match first (FTS5 MATCH + rank). Exposed over RPC; `heph search` and `heph journal` CLI commands added. 3 search integration tests (title/body match, edits reflected via trigger, tombstone exclusion, all insert paths indexed). 79 tests green. This completes the local feature surface; the remaining slices are the distributed/auth/nvim layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65 lines
2 KiB
Rust
65 lines
2 KiB
Rust
//! Full-text search over title + body via FTS5 (tech-spec §6, slice query-surface).
|
|
|
|
use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store};
|
|
|
|
fn store() -> LocalStore {
|
|
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn search_matches_title_and_body() {
|
|
let mut s = store();
|
|
let roof = s
|
|
.create_node(NewNode::doc(
|
|
"Roof repair",
|
|
"Called the contractor about shingles.",
|
|
))
|
|
.unwrap();
|
|
s.create_node(NewNode::doc("Garden", "Plant tomatoes in spring."))
|
|
.unwrap();
|
|
|
|
// Body term.
|
|
let hits = s.search("contractor").unwrap();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].id, roof.id);
|
|
|
|
// Title term.
|
|
let hits = s.search("roof").unwrap();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].id, roof.id);
|
|
|
|
// No match.
|
|
assert!(s.search("nonexistentword").unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_reflects_edits_and_excludes_tombstoned() {
|
|
let mut s = store();
|
|
let n = s.create_node(NewNode::doc("Notes", "alpha")).unwrap();
|
|
|
|
assert_eq!(s.search("alpha").unwrap().len(), 1);
|
|
assert!(s.search("bravo").unwrap().is_empty());
|
|
|
|
// Edit the body → FTS index follows via the update trigger.
|
|
s.update_node(&n.id, None, Some("bravo charlie".into()))
|
|
.unwrap();
|
|
assert!(s.search("alpha").unwrap().is_empty());
|
|
assert_eq!(s.search("bravo").unwrap().len(), 1);
|
|
|
|
// Tombstoned nodes drop out of results.
|
|
s.tombstone_node(&n.id).unwrap();
|
|
assert!(s.search("bravo").unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_indexes_all_node_insert_paths() {
|
|
// Nodes created through paths other than `create_node` (here a journal)
|
|
// are indexed too, since the FTS triggers fire on every nodes insert.
|
|
let mut s = store();
|
|
let j = s.journal_open_or_create("2026-05-31").unwrap();
|
|
s.update_node(&j.id, None, Some("dentist appointment".into()))
|
|
.unwrap();
|
|
let hits = s.search("dentist").unwrap();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].kind, NodeKind::Journal);
|
|
}
|