blob: 630a3bf93b3fc12d8ceed37fd8f78e643e9d5504 [file]
//! The search index: summaries first, filtered inside every query, rebuildable from the
//! record alone.
//!
//! The index is derived. It holds one row of metadata per entry and a full-text table
//! over title, summary, and body, ranked with BM25. Nothing here is authoritative: delete
//! the file, run [`Index::rebuild`] over the record, and the same index comes back.
//!
//! Every read takes a [`ReadFilter`] and applies it in SQL. A hit, a count, or a summary
//! never reflects an entry the filter does not admit, because the rows were never
//! selected. That is the property the adversarial pass attacks first; keep it in the
//! query, not in Rust after the query.
//!
//! This backend is SQLite with FTS5, one file, nothing to operate. A second backend
//! (Solr, Tantivy) would implement the same methods; the surface is what matters.
use dm_core::{Entry, EntryType, Label, ReadFilter, Status, TrustTier};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Row, TransactionBehavior};
use std::path::Path;
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
/// What can go wrong at the index.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// SQLite refused or failed.
#[error("index: {0}")]
Db(#[from] rusqlite::Error),
/// Another connection kept the write-ahead log from being checkpointed, so bytes
/// removed from the tables are still in it.
#[error(
"index: another connection holds the write-ahead log; what was removed is still in it"
)]
Held,
/// A row holds something the index did not write. The index is corrupt; rebuild it.
#[error("index row for {id}: {what}")]
BadRow {
/// The entry the row is for.
id: String,
/// What was wrong.
what: String,
},
}
/// One search result: everything the caller needs to decide whether to open the entry.
#[derive(Clone, Debug, PartialEq)]
pub struct Hit {
/// The entry's id.
pub id: String,
/// Its domain.
pub domain: String,
/// Its kind.
pub entry_type: EntryType,
/// How far it has been checked.
pub trust_tier: TrustTier,
/// How sensitive it is.
pub classification: Label,
/// The title the index shows.
pub title: String,
/// The one-line summary.
pub summary: String,
/// BM25 rank. Lower is better; zero for keyed lookups that have no query.
pub score: f64,
/// Whether the entry's `review_by` has passed: the decay clock the paper gives facts,
/// shown with the hit so a reader sees it before leaning on it.
pub review_due: bool,
}
/// An entry's indexed metadata, without its body.
#[derive(Clone, Debug, PartialEq)]
pub struct Meta {
/// The hit-shaped part.
pub hit: Hit,
/// Who wrote it.
pub author: String,
/// When.
pub created: OffsetDateTime,
/// Its decay clock, if any.
pub review_by: Option<Date>,
/// Its lifecycle state; `None` means active.
pub status: Option<Status>,
/// The entry it replaces, if any.
pub supersedes: Option<String>,
/// Where it came from, if imported or tapped.
pub origin: Option<String>,
/// Digest of the rendered entry at indexing time.
pub digest: String,
}
/// The index, over one SQLite connection.
pub struct Index {
conn: Connection,
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
entry_type TEXT NOT NULL,
author TEXT NOT NULL,
created TEXT NOT NULL,
created_unix INTEGER,
created_nanos INTEGER,
review_by TEXT,
tier_rank INTEGER NOT NULL,
label_rank INTEGER NOT NULL,
status TEXT,
supersedes TEXT,
origin TEXT,
title TEXT NOT NULL,
summary TEXT NOT NULL,
digest TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS entries_domain ON entries (domain);
CREATE INDEX IF NOT EXISTS entries_review ON entries (review_by);
CREATE INDEX IF NOT EXISTS entries_origin ON entries (origin);
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5 (
id UNINDEXED, title, summary, body, tokenize = 'porter unicode61'
);
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema', '1');
";
/// The order of `created`: whole seconds since the Unix epoch, then the nanoseconds within
/// that second. `created` itself is the frontmatter's RFC 3339 text, which does not sort
/// as text: `10:00:00Z` sorts after `10:00:00.5Z`, `.15Z` before `.1Z`, and an offset other
/// than UTC by its local hour. Both are integers, so a year at either end of what the
/// frontmatter accepts still has a place. An older binary writes rows without them: the
/// trigger [`SORT_KEY_TRIGGER`] fills those in from `created` as the row lands, to the
/// millisecond, and [`Index::sort_keys`] fills in any left on the next open.
const NEWEST_FIRST: &str = "e.created_unix DESC, e.created_nanos DESC";
/// Lives in the file, so it fires for whichever binary inserts the row.
const SORT_KEY_TRIGGER: &str = "
CREATE TRIGGER IF NOT EXISTS entries_sort_key AFTER INSERT ON entries
WHEN NEW.created_unix IS NULL
BEGIN
UPDATE entries SET
created_unix = unixepoch(NEW.created),
created_nanos = min(999, CAST(
(unixepoch(NEW.created, 'subsec') - unixepoch(NEW.created)) * 1000 + 0.5
AS INTEGER)) * 1000000
WHERE id = NEW.id;
END;";
fn sort_key(at: OffsetDateTime) -> (i64, i64) {
(at.unix_timestamp(), i64::from(at.nanosecond()))
}
const HIT_COLUMNS: &str =
"e.id, e.domain, e.entry_type, e.tier_rank, e.label_rank, e.title, e.summary, e.review_by";
fn tier_from_rank(id: &str, rank: i64) -> Result<TrustTier, Error> {
match rank {
0 => Ok(TrustTier::Raw),
1 => Ok(TrustTier::Curated),
2 => Ok(TrustTier::Verified),
other => Err(Error::BadRow {
id: id.to_string(),
what: format!("tier rank {other}"),
}),
}
}
fn label_from_rank(id: &str, rank: i64) -> Result<Label, Error> {
match rank {
0 => Ok(Label::Public),
1 => Ok(Label::Internal),
2 => Ok(Label::Restricted),
other => Err(Error::BadRow {
id: id.to_string(),
what: format!("label rank {other}"),
}),
}
}
fn status_from_text(id: &str, s: Option<String>) -> Result<Option<Status>, Error> {
match s.as_deref() {
None => Ok(None),
Some("demoted") => Ok(Some(Status::Demoted)),
Some("superseded") => Ok(Some(Status::Superseded)),
Some("erased") => Ok(Some(Status::Erased)),
Some(other) => Err(Error::BadRow {
id: id.to_string(),
what: format!("status {other:?}"),
}),
}
}
fn status_to_text(s: Option<Status>) -> Option<&'static str> {
s.map(|s| match s {
Status::Demoted => "demoted",
Status::Superseded => "superseded",
Status::Erased => "erased",
})
}
fn hit_from_row(row: &Row<'_>, score: f64) -> Result<Hit, Error> {
let id: String = row.get(0)?;
let entry_type: String = row.get(2)?;
let entry_type = entry_type.parse().map_err(|_| Error::BadRow {
id: id.clone(),
what: format!("entry type {entry_type:?}"),
})?;
// the decay clock, read at query time: an entry past its review date is served
// with that said, so the reader sees it before leaning on it
let review_by: Option<String> = row.get(7)?;
let today = OffsetDateTime::now_utc().date();
let review_due = review_by
.as_deref()
.and_then(|d| Date::parse(d, &time::format_description::well_known::Iso8601::DATE).ok())
.is_some_and(|d| d <= today);
Ok(Hit {
trust_tier: tier_from_rank(&id, row.get(3)?)?,
classification: label_from_rank(&id, row.get(4)?)?,
id,
domain: row.get(1)?,
entry_type,
title: row.get(5)?,
summary: row.get(6)?,
score,
review_due,
})
}
/// Turn free text into an FTS5 query that cannot fail to parse: every whitespace-separated
/// token becomes a quoted phrase, and the phrases are ANDed. `None` when there is nothing
/// to search for.
/// The FTS5 query for a text: every word a quoted phrase, so user input can never be
/// query syntax; joined with AND (implicit) or, for the fallback, OR.
fn fts_query(text: &str, any_word: bool) -> Option<String> {
let parts: Vec<String> = text
.split_whitespace()
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
.collect();
if parts.is_empty() {
None
} else if any_word {
Some(parts.join(" OR "))
} else {
Some(parts.join(" "))
}
}
/// The WHERE fragment and parameters a filter compiles to. Positional parameters start at
/// `first`, so the caller can put its own before them.
fn compile_filter(f: &ReadFilter, first: usize) -> (String, Vec<rusqlite::types::Value>) {
let mut sql = format!(
" AND e.label_rank <= ?{} AND e.tier_rank >= ?{}",
first,
first + 1
);
let mut values: Vec<rusqlite::types::Value> =
vec![(f.handling as i64).into(), (f.min_tier as i64).into()];
if !f.include_inactive {
sql.push_str(" AND e.status IS NULL");
}
if !f.domains.is_empty() {
let marks: Vec<String> = (0..f.domains.len())
.map(|i| format!("?{}", first + 2 + i))
.collect();
sql.push_str(&format!(" AND e.domain IN ({})", marks.join(", ")));
values.extend(f.domains.iter().map(|d| d.clone().into()));
}
(sql, values)
}
/// The longest chain of replacements [`Index::replaced_by`] walks, against a cycle.
const MAX_CHAIN: usize = 64;
impl Index {
/// Open or create the index file.
///
/// # Errors
///
/// When the file cannot be opened or the schema cannot be created.
pub fn open(path: &Path) -> Result<Index, Error> {
let conn = Connection::open(path)?;
Index::init(conn)
}
/// An index that lives only in memory.
///
/// # Errors
///
/// When SQLite cannot create the schema, which it can always do in memory.
pub fn in_memory() -> Result<Index, Error> {
Index::init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> Result<Index, Error> {
// secure_delete overwrites what a delete frees, so a removed body does not wait
// in a free page for the next write
conn.execute_batch(
"PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON;",
)?;
conn.execute_batch(SCHEMA)?;
let mut ix = Index { conn };
ix.sort_keys()?;
Ok(ix)
}
/// Give every row its sort key: add the columns to an index made before they existed,
/// and fill in the rows written without them. Looks first without a lock, so an open
/// with nothing to do writes nothing; the work itself holds the write lock from its
/// first read, so two processes opening at once do it once.
fn sort_keys(&mut self) -> Result<(), Error> {
if self.sort_keys_done()? {
return Ok(());
}
let tx = self
.conn
.transaction_with_behavior(TransactionBehavior::Immediate)?;
let has: i64 = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('entries') WHERE name = 'created_unix'",
[],
|r| r.get(0),
)?;
if has == 0 {
tx.execute_batch(
"ALTER TABLE entries ADD COLUMN created_unix INTEGER;
ALTER TABLE entries ADD COLUMN created_nanos INTEGER;",
)?;
}
tx.execute_batch(
"CREATE INDEX IF NOT EXISTS entries_created ON entries (created_unix, created_nanos);",
)?;
tx.execute_batch(SORT_KEY_TRIGGER)?;
let rows: Vec<(String, String)> = {
let mut stmt =
tx.prepare("SELECT id, created FROM entries WHERE created_unix IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<Result<_, _>>()?
};
{
let mut update = tx.prepare(
"UPDATE entries SET created_unix = ?1, created_nanos = ?2 WHERE id = ?3",
)?;
for (id, created) in rows {
// a value that does not parse is left for the read to report as a bad row
if let Ok(at) = OffsetDateTime::parse(&created, &Rfc3339) {
let (unix, nanos) = sort_key(at);
update.execute(params![unix, nanos, id])?;
}
}
}
tx.commit()?;
Ok(())
}
fn sort_keys_done(&self) -> Result<bool, Error> {
let has: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('entries') WHERE name = 'created_unix'",
[],
|r| r.get(0),
)?;
if has == 0 {
return Ok(false);
}
let indexed: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE (type = 'index' AND name = 'entries_created')
OR (type = 'trigger' AND name = 'entries_sort_key')",
[],
|r| r.get(0),
)?;
let missing: Option<i64> = self
.conn
.query_row(
"SELECT 1 FROM entries WHERE created_unix IS NULL LIMIT 1",
[],
|r| r.get(0),
)
.optional()?;
Ok(indexed == 2 && missing.is_none())
}
/// Index an entry, replacing whatever the index held for its id.
///
/// # Errors
///
/// A database error.
pub fn upsert(&mut self, entry: &Entry) -> Result<(), Error> {
let tx = self.conn.transaction()?;
Index::upsert_in(&tx, entry)?;
tx.commit()?;
Ok(())
}
fn upsert_in(tx: &rusqlite::Transaction<'_>, entry: &Entry) -> Result<(), Error> {
let f = &entry.front;
let created = f
.created
.format(&Rfc3339)
.unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z"));
let review_by = f.review_by.map(|d| d.to_string());
let (created_unix, created_nanos) = sort_key(f.created);
tx.execute(
"INSERT OR REPLACE INTO entries
(id, domain, entry_type, author, created, review_by, tier_rank, label_rank,
status, supersedes, origin, title, summary, digest, created_unix, created_nanos)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
f.id,
f.domain,
f.entry_type.to_string(),
f.author,
created,
review_by,
f.trust_tier as i64,
f.classification as i64,
status_to_text(f.status),
f.supersedes,
f.origin,
entry.title(),
entry.summary(),
dm_core::digest(&entry.render()),
created_unix,
created_nanos,
],
)?;
tx.execute("DELETE FROM entries_fts WHERE id = ?1", params![f.id])?;
tx.execute(
"INSERT INTO entries_fts (id, title, summary, body) VALUES (?1, ?2, ?3, ?4)",
params![f.id, entry.title(), entry.summary(), entry.body],
)?;
Ok(())
}
/// Forget an entry. True if the index held it.
///
/// # Errors
///
/// A database error.
pub fn remove(&mut self, id: &str) -> Result<bool, Error> {
let tx = self.conn.transaction()?;
let n = tx.execute("DELETE FROM entries WHERE id = ?1", params![id])?;
tx.execute("DELETE FROM entries_fts WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(n > 0)
}
/// Leave no trace of what was removed: the full-text index merges its segments so
/// deleted documents drop out of them, and the file gives its free pages back, so the
/// bytes of a removed body are gone from disk rather than waiting to be overwritten.
///
/// # Errors
///
/// A database error.
pub fn vacuum(&mut self) -> Result<(), Error> {
self.conn
.execute_batch("INSERT INTO entries_fts(entries_fts) VALUES('optimize');")?;
self.checkpoint()?;
self.conn.execute_batch("VACUUM;")?;
self.checkpoint()
}
/// Fold the write-ahead log into the file and truncate it, waiting a little for a
/// reader mid-query; [`Error::Held`] when it still could not, since the log then
/// still holds what the tables no longer do.
fn checkpoint(&self) -> Result<(), Error> {
// a short wait per try: a held log is answered, not waited out
self.conn
.busy_timeout(std::time::Duration::from_millis(200))?;
let mut held = true;
for _ in 0..5 {
let busy: i64 = self
.conn
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
if busy == 0 {
held = false;
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
self.conn.busy_timeout(std::time::Duration::from_secs(5))?;
if held {
Err(Error::Held)
} else {
Ok(())
}
}
/// Replace the whole index with what the record holds. One transaction: a reader
/// sees the old index or the new one, never half.
///
/// # Errors
///
/// A database error; the index is left as it was.
pub fn rebuild<I>(&mut self, entries: I) -> Result<usize, Error>
where
I: IntoIterator<Item = Entry>,
{
let tx = self.conn.transaction()?;
tx.execute_batch("DELETE FROM entries; DELETE FROM entries_fts;")?;
let mut n = 0;
for e in entries {
Index::upsert_in(&tx, &e)?;
n += 1;
}
tx.commit()?;
Ok(n)
}
/// Replace one domain's part of the index with `entries`, in one transaction: what a
/// domain's materializer builds, leaving every other domain as it was.
///
/// # Errors
///
/// A database error; the index is left as it was.
pub fn rebuild_domain<I>(&mut self, domain: &str, entries: I) -> Result<usize, Error>
where
I: IntoIterator<Item = Entry>,
{
let tx = self.conn.transaction()?;
tx.execute(
"DELETE FROM entries_fts WHERE id IN (SELECT id FROM entries WHERE domain = ?1)",
params![domain],
)?;
tx.execute("DELETE FROM entries WHERE domain = ?1", params![domain])?;
let mut n = 0;
for e in entries {
Index::upsert_in(&tx, &e)?;
n += 1;
}
tx.commit()?;
Ok(n)
}
/// Ranked full-text search over title, summary, and body, within the filter.
/// Summaries only; the caller opens an entry with `get` if a summary warrants it.
///
/// # Errors
///
/// A database error, or a row the index did not write.
pub fn search(
&self,
query: &str,
filter: &ReadFilter,
limit: usize,
) -> Result<Vec<Hit>, Error> {
let Some(q) = fts_query(query, false) else {
return Ok(Vec::new());
};
let hits = self.search_fts(&q, filter, limit)?;
if !hits.is_empty() || query.split_whitespace().count() < 2 {
return Ok(hits);
}
// every word as a phrase found nothing: a question in natural language rather
// than a known item. Any word, still ranked by BM25, so the best partial match
// leads. Explainable either way.
match fts_query(query, true) {
Some(q) => self.search_fts(&q, filter, limit),
None => Ok(hits),
}
}
fn search_fts(&self, q: &str, filter: &ReadFilter, limit: usize) -> Result<Vec<Hit>, Error> {
let (where_sql, mut values) = compile_filter(filter, 2);
let sql = format!(
"SELECT {HIT_COLUMNS}, bm25(entries_fts, 0.0, 10.0, 5.0, 1.0) AS score
FROM entries_fts f JOIN entries e ON e.id = f.id
WHERE entries_fts MATCH ?1{where_sql}
ORDER BY score, {NEWEST_FIRST}
LIMIT {}",
limit.max(1)
);
values.insert(0, q.to_string().into());
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(values), |row| {
let score: f64 = row.get(8)?;
Ok(hit_from_row(row, score))
})?;
rows.map(|r| r?).collect()
}
/// How many entries the filter admits. The same filter as a search, so the count
/// cannot reveal what the search would not.
///
/// # Errors
///
/// A database error.
pub fn count(&self, filter: &ReadFilter) -> Result<u64, Error> {
let (where_sql, values) = compile_filter(filter, 1);
let sql = format!("SELECT COUNT(*) FROM entries e WHERE 1 = 1{where_sql}");
let n: i64 = self
.conn
.query_row(&sql, params_from_iter(values), |r| r.get(0))?;
Ok(u64::try_from(n).unwrap_or(0))
}
/// Entries of one kind, or of any kind, newest first, within the filter. The keyed
/// lookup: handoffs for a domain, decisions for a domain.
///
/// # Errors
///
/// A database error, or a row the index did not write.
pub fn list(
&self,
filter: &ReadFilter,
kind: Option<EntryType>,
limit: usize,
) -> Result<Vec<Hit>, Error> {
let (where_sql, mut values) = compile_filter(filter, 2);
let kind_sql = if kind.is_some() {
" AND e.entry_type = ?1"
} else {
" AND ?1 = ?1"
};
values.insert(0, kind.map_or_else(String::new, |k| k.to_string()).into());
let sql = format!(
"SELECT {HIT_COLUMNS} FROM entries e WHERE 1 = 1{kind_sql}{where_sql}
ORDER BY {NEWEST_FIRST} LIMIT {}",
limit.max(1)
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(values), |row| Ok(hit_from_row(row, 0.0)))?;
rows.map(|r| r?).collect()
}
/// Entries whose review date is on or before `on`, soonest first, within the filter.
///
/// # Errors
///
/// A database error, or a row the index did not write.
pub fn due_for_review(
&self,
filter: &ReadFilter,
on: Date,
limit: usize,
) -> Result<Vec<Hit>, Error> {
let (where_sql, mut values) = compile_filter(filter, 2);
values.insert(0, on.to_string().into());
let sql = format!(
"SELECT {HIT_COLUMNS} FROM entries e
WHERE e.review_by IS NOT NULL AND e.review_by <= ?1{where_sql}
ORDER BY e.review_by, e.created_unix, e.created_nanos LIMIT {}",
limit.max(1)
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(values), |row| Ok(hit_from_row(row, 0.0)))?;
rows.map(|r| r?).collect()
}
/// Where a reader of a replaced entry goes: the end of its chain of successors,
/// the newest active entry the filter admits, resolved in one query however many
/// times the entry was replaced, so a reader is redirected once, not hop by hop.
/// `None` when no active successor is admitted.
///
/// # Errors
///
/// A database error.
pub fn replaced_by(&self, id: &str, filter: &ReadFilter) -> Result<Option<String>, Error> {
let (where_sql, mut values) = compile_filter(filter, 2);
values.insert(0, id.to_string().into());
let sql = format!(
"WITH RECURSIVE chain(id, depth) AS (
SELECT e.id, 1 FROM entries e WHERE e.supersedes = ?1
UNION
SELECT e.id, c.depth + 1 FROM entries e JOIN chain c ON e.supersedes = c.id
WHERE c.depth < {MAX_CHAIN}
)
SELECT e.id FROM chain c JOIN entries e ON e.id = c.id
WHERE e.status IS NULL{where_sql}
ORDER BY c.depth DESC, {NEWEST_FIRST} LIMIT 1"
);
let mut stmt = self.conn.prepare(&sql)?;
let mut rows = stmt.query(params_from_iter(values))?;
Ok(match rows.next()? {
Some(row) => Some(row.get(0)?),
None => None,
})
}
/// One entry's metadata, if the filter admits it. `None` both when the entry is absent
/// and when it is not for this session to see; a caller cannot tell the two apart.
///
/// # Errors
///
/// A database error, or a row the index did not write.
pub fn meta(&self, id: &str, filter: &ReadFilter) -> Result<Option<Meta>, Error> {
let (where_sql, mut values) = compile_filter(filter, 2);
values.insert(0, id.to_string().into());
let sql = format!(
"SELECT {HIT_COLUMNS}, e.author, e.created, e.review_by, e.status, e.supersedes,
e.origin, e.digest
FROM entries e WHERE e.id = ?1{where_sql}"
);
let mut stmt = self.conn.prepare(&sql)?;
let row = stmt
.query_row(params_from_iter(values), |row| {
// the hit's own columns come first, review_by among them now
let hit = hit_from_row(row, 0.0);
let created: String = row.get(9)?;
let review_by: Option<String> = row.get(10)?;
let status: Option<String> = row.get(11)?;
Ok((
hit,
row.get::<_, String>(8)?,
created,
review_by,
status,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(13)?,
row.get::<_, String>(14)?,
))
})
.optional()?;
let Some((hit, author, created, review_by, status, supersedes, origin, digest)) = row
else {
return Ok(None);
};
let hit = hit?;
let created = OffsetDateTime::parse(&created, &Rfc3339).map_err(|_| Error::BadRow {
id: hit.id.clone(),
what: format!("created {created:?}"),
})?;
let review_by = match review_by {
None => None,
Some(s) => Some(
Date::parse(&s, &time::format_description::well_known::Iso8601::DATE).map_err(
|_| Error::BadRow {
id: hit.id.clone(),
what: format!("review_by {s:?}"),
},
)?,
),
};
let status = status_from_text(&hit.id, status)?;
Ok(Some(Meta {
hit,
author,
created,
review_by,
status,
supersedes,
origin,
digest,
}))
}
/// The id already indexed in `domain` with this origin, if any. Unfiltered by label
/// on purpose: it serves the import path's dedup, which runs as the owner, and
/// reveals only an id. Scoped to the domain, since one domain's record says nothing
/// about another's.
///
/// # Errors
///
/// A database error.
pub fn by_origin(&self, domain: &str, origin: &str) -> Result<Option<String>, Error> {
Ok(self
.conn
.query_row(
"SELECT id FROM entries WHERE domain = ?1 AND origin = ?2 LIMIT 1",
params![domain, origin],
|r| r.get(0),
)
.optional()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dm_core::Frontmatter;
use time::macros::{date, datetime};
fn entry(domain: &str, id: &str, title: &str, body: &str) -> Entry {
Entry {
front: Frontmatter {
id: id.into(),
entry_type: EntryType::Fact,
domain: domain.into(),
author: "human:test:me".into(),
created: datetime!(2026-10-01 10:00 UTC),
source: vec![],
confidence: None,
trust_tier: TrustTier::Raw,
review_by: None,
supersedes: None,
policy_context: vec![],
classification: Label::Public,
status: None,
origin: None,
summary: None,
title: Some(title.into()),
},
body: body.into(),
}
}
fn ids(hits: &[Hit]) -> Vec<&str> {
hits.iter().map(|h| h.id.as_str()).collect()
}
fn any() -> ReadFilter {
ReadFilter::any_domain(Label::Restricted)
}
#[test]
fn search_finds_by_body_and_ranks_title_matches_first() {
let mut ix = Index::in_memory().unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-body",
"Unrelated title",
"The retry policy uses backoff.\n",
))
.unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-title",
"Retry policy",
"Nothing else here.\n",
))
.unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-none",
"Deployment notes",
"Ship on Fridays.\n",
))
.unwrap();
let hits = ix.search("retry policy", &any(), 10).unwrap();
assert_eq!(ids(&hits), vec!["mem-a-title", "mem-a-body"]);
assert!(hits[0].score < hits[1].score, "bm25: lower is better");
assert_eq!(hits[0].summary, "Nothing else here.");
assert!(ix.search("fridays", &any(), 10).unwrap().len() == 1);
assert!(ix.search("nowhere", &any(), 10).unwrap().is_empty());
}
#[test]
fn the_filter_is_applied_inside_the_query() {
let mut ix = Index::in_memory().unwrap();
let mut secret = entry("dom-a", "mem-a-secret", "Budget", "The secret budget.\n");
secret.front.classification = Label::Restricted;
let mut internal = entry(
"dom-a",
"mem-a-internal",
"Budget",
"The internal budget.\n",
);
internal.front.classification = Label::Internal;
let public = entry("dom-a", "mem-a-public", "Budget", "The public budget.\n");
let other = entry(
"dom-b",
"mem-b-public",
"Budget",
"Another domain's budget.\n",
);
let mut gone = entry("dom-a", "mem-a-gone", "Budget", "A demoted budget.\n");
gone.front.status = Some(Status::Demoted);
for e in [&secret, &internal, &public, &other, &gone] {
ix.upsert(e).unwrap();
}
let f = ReadFilter::for_domains(["dom-a"], Label::Internal);
let hits = ix.search("budget", &f, 10).unwrap();
let mut got = ids(&hits);
got.sort_unstable();
assert_eq!(got, vec!["mem-a-internal", "mem-a-public"]);
assert_eq!(
ix.count(&f).unwrap(),
2,
"the count sees what the search sees"
);
assert!(
ix.meta("mem-a-secret", &f).unwrap().is_none(),
"meta hides what the filter hides"
);
assert!(ix.meta("mem-a-public", &f).unwrap().is_some());
assert!(ix.meta("mem-b-public", &f).unwrap().is_none(), "domain");
let mut wide = f.clone();
wide.include_inactive = true;
assert_eq!(
ix.count(&wide).unwrap(),
3,
"inactive entries come back only when asked for"
);
let mut curated = f.clone();
curated.min_tier = TrustTier::Curated;
assert_eq!(ix.count(&curated).unwrap(), 0, "tier floor");
assert_eq!(ix.count(&any()).unwrap(), 4);
}
#[test]
fn a_question_falls_back_to_any_word_when_no_entry_has_them_all() {
let mut ix = Index::in_memory().unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-dr19",
"DR-19: does the wiki stay on MoinMoin",
"MoinMoin cannot rank full text without Xapian beside it.\n",
))
.unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-commits",
"Commit messages",
"Short subject, body says why.\n",
))
.unwrap();
let hits = ix
.search("what did DR-19 decide about MoinMoin", &any(), 10)
.unwrap();
assert_eq!(ids(&hits), vec!["mem-a-dr19"], "any-word fallback finds it");
assert!(ix
.search("nothing matches this at all", &any(), 10)
.unwrap()
.is_empty());
assert!(
ix.search("zzzz", &any(), 10).unwrap().is_empty(),
"a single unknown word has no fallback"
);
}
#[test]
fn query_syntax_from_a_user_cannot_break_the_search() {
let mut ix = Index::in_memory().unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-1",
"Quotes",
"She said \"hello\" and left.\n",
))
.unwrap();
for q in [
"\"unbalanced",
"foo OR bar)",
"NEAR(",
"a*",
"-",
"\"\"",
"hello\"",
] {
assert!(ix.search(q, &any(), 10).is_ok(), "{q:?}");
}
assert_eq!(
ids(&ix.search("\"hello\"", &any(), 10).unwrap()),
vec!["mem-a-1"]
);
assert!(ix.search(" ", &any(), 10).unwrap().is_empty());
assert_eq!(
fts_query("a \"b\" c", false),
Some("\"a\" \"\"\"b\"\"\" \"c\"".into())
);
}
#[test]
fn rebuild_from_the_record_replaces_everything() {
let mut ix = Index::in_memory().unwrap();
ix.upsert(&entry(
"dom-a",
"mem-a-stale",
"Stale",
"Gone from the record.\n",
))
.unwrap();
let fresh = vec![
entry("dom-a", "mem-a-1", "One", "first\n"),
entry("dom-a", "mem-a-2", "Two", "second\n"),
];
assert_eq!(ix.rebuild(fresh.clone()).unwrap(), 2);
assert_eq!(ix.count(&any()).unwrap(), 2);
assert!(ix.search("gone", &any(), 10).unwrap().is_empty());
let mut incremental = Index::in_memory().unwrap();
for e in &fresh {
incremental.upsert(e).unwrap();
}
assert_eq!(
ix.search("first second", &any(), 10).unwrap(),
incremental.search("first second", &any(), 10).unwrap(),
"a rebuild and an incremental index answer alike"
);
assert_eq!(
ids(&ix.search("second", &any(), 10).unwrap()),
ids(&incremental.search("second", &any(), 10).unwrap())
);
}
#[test]
fn upsert_replaces_and_remove_forgets() {
let mut ix = Index::in_memory().unwrap();
ix.upsert(&entry("dom-a", "mem-a-1", "Old title", "alpha\n"))
.unwrap();
ix.upsert(&entry("dom-a", "mem-a-1", "New title", "beta\n"))
.unwrap();
assert!(ix.search("alpha", &any(), 10).unwrap().is_empty());
assert_eq!(ix.search("beta", &any(), 10).unwrap()[0].title, "New title");
assert_eq!(ix.count(&any()).unwrap(), 1);
assert!(ix.remove("mem-a-1").unwrap());
assert!(!ix.remove("mem-a-1").unwrap());
assert_eq!(ix.count(&any()).unwrap(), 0);
assert!(ix.search("beta", &any(), 10).unwrap().is_empty());
}
#[test]
fn keyed_lookups_list_and_due_for_review() {
let mut ix = Index::in_memory().unwrap();
let mut h = entry(
"dom-a",
"mem-a-handoff",
"Stopped at step 3",
"Resume there.\n",
);
h.front.entry_type = EntryType::Handoff;
h.front.created = datetime!(2026-10-02 09:00 UTC);
let mut fact = entry("dom-a", "mem-a-fact", "Roster", "Three members.\n");
fact.front.review_by = Some(date!(2026 - 11 - 01));
let mut old_fact = entry("dom-a", "mem-a-old", "Versions", "Two releases.\n");
old_fact.front.review_by = Some(date!(2026 - 09 - 01));
for e in [&h, &fact, &old_fact] {
ix.upsert(e).unwrap();
}
assert_eq!(
ids(&ix.list(&any(), Some(EntryType::Handoff), 10).unwrap()),
vec!["mem-a-handoff"]
);
let all = ix.list(&any(), None, 10).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, "mem-a-handoff", "newest first");
assert_eq!(
ids(&ix
.due_for_review(&any(), date!(2026 - 10 - 01), 10)
.unwrap()),
vec!["mem-a-old"]
);
assert_eq!(
ids(&ix
.due_for_review(&any(), date!(2026 - 12 - 01), 10)
.unwrap()),
vec!["mem-a-old", "mem-a-fact"]
);
}
// as RFC 3339 text, each of these sorts out of time order against another: a shorter
// fraction, no fraction, and an offset other than UTC
const WITHIN_A_SECOND: [(&str, OffsetDateTime); 4] = [
("mem-a-whole", datetime!(2026-10-01 10:00:00 UTC)),
("mem-a-tenth", datetime!(2026-10-01 10:00:00.1 UTC)),
("mem-a-tenth-and", datetime!(2026-10-01 10:00:00.15 UTC)),
("mem-a-paris", datetime!(2026-10-01 10:59:59 +01:00)),
];
const IN_TIME_ORDER: [&str; 4] = [
"mem-a-tenth-and",
"mem-a-tenth",
"mem-a-whole",
"mem-a-paris",
];
fn holding(at: &[(&str, OffsetDateTime)]) -> Index {
let mut ix = Index::in_memory().unwrap();
for (id, created) in at {
let mut e = entry("dom-a", id, id, "Body.\n");
e.front.created = *created;
ix.upsert(&e).unwrap();
}
ix
}
#[test]
fn list_is_newest_first_within_a_second_and_across_offsets() {
let ix = holding(&WITHIN_A_SECOND);
assert_eq!(ids(&ix.list(&any(), None, 10).unwrap()), IN_TIME_ORDER);
}
#[test]
fn a_created_at_either_end_of_the_years_is_indexed_ordered_and_read_back() {
// each is a year the frontmatter accepts whose UTC instant is a year outside it
// and the second sorts after the first as text, though it is earlier
let ends = [
("mem-a-late", datetime!(9999-12-31 23:30:00 -01:00)),
("mem-a-late-paris", datetime!(9999-12-31 23:45:00 +01:00)),
("mem-a-early", datetime!(0000-01-01 00:30:00 +01:00)),
("mem-a-now", datetime!(2026-10-01 10:00:00 UTC)),
];
let ix = holding(&ends);
assert_eq!(
ids(&ix.list(&any(), None, 10).unwrap()),
["mem-a-late", "mem-a-late-paris", "mem-a-now", "mem-a-early"]
);
for (id, created) in ends {
let m = ix.meta(id, &any()).unwrap().unwrap();
assert_eq!(m.created, created, "{id}");
}
}
#[test]
fn due_for_review_and_replaced_by_order_by_the_instant() {
let mut ix = Index::in_memory().unwrap();
for (id, created) in WITHIN_A_SECOND {
let mut e = entry("dom-a", id, id, "Body.\n");
e.front.created = created;
e.front.review_by = Some(date!(2026 - 10 - 01));
e.front.supersedes = Some("mem-a-first".into());
ix.upsert(&e).unwrap();
}
let mut oldest_first = IN_TIME_ORDER;
oldest_first.reverse();
assert_eq!(
ids(&ix
.due_for_review(&any(), date!(2026 - 10 - 01), 10)
.unwrap()),
oldest_first
);
assert_eq!(
ix.replaced_by("mem-a-first", &any()).unwrap().as_deref(),
Some(IN_TIME_ORDER[0])
);
}
/// A row as a binary from before the sort key writes it: the columns it names.
fn insert_as_before(conn: &Connection, id: &str, created: OffsetDateTime) {
conn.execute(
"INSERT OR REPLACE INTO entries
(id, domain, entry_type, author, created, review_by, tier_rank, label_rank,
status, supersedes, origin, title, summary, digest)
VALUES (?1, 'dom-a', 'fact', 'human:test:me', ?2, NULL, 0, 0,
NULL, NULL, NULL, ?1, '', '')",
params![id, created.format(&Rfc3339).unwrap()],
)
.unwrap();
}
#[test]
fn an_index_file_from_before_the_sort_key_is_ordered_on_open_and_after() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.db");
{
let conn = Connection::open(&path).unwrap();
let before = SCHEMA.replace(
" created_unix INTEGER,\n created_nanos INTEGER,\n",
"",
);
assert_ne!(before, SCHEMA);
conn.execute_batch(&before).unwrap();
for (id, created) in WITHIN_A_SECOND {
insert_as_before(&conn, id, created);
}
}
let ix = Index::open(&path).unwrap();
assert_eq!(ids(&ix.list(&any(), None, 10).unwrap()), IN_TIME_ORDER);
assert!(ix.sort_keys_done().unwrap());
// an older binary goes on writing to the file after a newer one opened it
insert_as_before(
&ix.conn,
"mem-a-later",
datetime!(2026-10-01 10:00:00.2 UTC),
);
assert_eq!(
ids(&ix.list(&any(), None, 10).unwrap())[0],
"mem-a-later",
"an older binary's row lands without the order"
);
// past the trigger's reach: SQLite reads no instant after the year 9999, and this
// one is in 10000 in UTC, so only the next open can key it
insert_as_before(
&ix.conn,
"mem-a-last",
datetime!(9999-12-31 23:30:00 -01:00),
);
assert!(!ix.sort_keys_done().unwrap());
drop(ix);
let ix = Index::open(&path).unwrap();
assert_eq!(
ids(&ix.list(&any(), None, 10).unwrap())[..2],
["mem-a-last", "mem-a-later"],
"a row the trigger could not key stays unkeyed after the next open"
);
}
#[test]
fn meta_round_trips_and_origin_dedups() {
let mut ix = Index::in_memory().unwrap();
let mut e = entry("dom-a", "mem-a-1", "Imported", "From elsewhere.\n");
e.front.origin = Some("github:apache/x#12".into());
e.front.review_by = Some(date!(2027 - 01 - 01));
e.front.supersedes = Some("mem-a-0".into());
e.front.status = Some(Status::Superseded);
ix.upsert(&e).unwrap();
let mut f = any();
f.include_inactive = true;
let m = ix.meta("mem-a-1", &f).unwrap().unwrap();
assert_eq!(m.author, "human:test:me");
assert_eq!(m.created, datetime!(2026-10-01 10:00 UTC));
assert_eq!(m.review_by, Some(date!(2027 - 01 - 01)));
assert_eq!(m.status, Some(Status::Superseded));
assert_eq!(m.supersedes.as_deref(), Some("mem-a-0"));
assert_eq!(m.origin.as_deref(), Some("github:apache/x#12"));
assert_eq!(m.digest, dm_core::digest(&e.render()));
assert_eq!(m.hit.title, "Imported");
assert_eq!(
ix.by_origin("dom-a", "github:apache/x#12")
.unwrap()
.as_deref(),
Some("mem-a-1")
);
assert_eq!(ix.by_origin("dom-a", "github:apache/x#13").unwrap(), None);
assert_eq!(
ix.by_origin("dom-b", "github:apache/x#12").unwrap(),
None,
"another domain's entry is not this domain's duplicate"
);
assert!(ix.meta("mem-a-none", &f).unwrap().is_none());
}
#[test]
fn the_file_persists_across_opens() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.db");
{
let mut ix = Index::open(&path).unwrap();
ix.upsert(&entry("dom-a", "mem-a-1", "Persisted", "still here\n"))
.unwrap();
}
let ix = Index::open(&path).unwrap();
assert_eq!(
ids(&ix.search("still", &any(), 10).unwrap()),
vec!["mem-a-1"]
);
}
/// Bytes anywhere in the index's files: the database and whatever journal it keeps.
fn index_bytes(dir: &std::path::Path) -> Vec<u8> {
let mut all = Vec::new();
for f in std::fs::read_dir(dir).unwrap().flatten() {
all.extend(std::fs::read(f.path()).unwrap());
}
all
}
#[test]
fn a_removed_body_leaves_no_bytes_behind_once_vacuumed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.db");
let marker = b"passphrase-Qz7xx";
{
let mut ix = Index::open(&path).unwrap();
let body = format!(
"The body says {m} twice: {m}.\n",
m = String::from_utf8_lossy(marker)
);
ix.upsert(&entry("dom-a", "mem-a-1", "A title", &body))
.unwrap();
assert!(ix.remove("mem-a-1").unwrap());
ix.vacuum().unwrap();
}
let bytes = index_bytes(dir.path());
assert!(
!bytes.windows(marker.len()).any(|w| w == marker),
"the removed body is still in the index's files"
);
}
#[test]
fn vacuum_says_so_when_another_connection_keeps_the_log() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.db");
let marker = b"passphrase-Qz7xx";
let mut ix = Index::open(&path).unwrap();
let body = format!("The body says {m}.\n", m = String::from_utf8_lossy(marker));
ix.upsert(&entry("dom-a", "mem-a-1", "A title", &body))
.unwrap();
ix.upsert(&entry("dom-a", "mem-a-2", "Other", "kept\n"))
.unwrap();
// another process, mid-query, holds a read snapshot of the log
let reader = Connection::open(&path).unwrap();
reader
.busy_timeout(std::time::Duration::from_millis(100))
.unwrap();
let mut stmt = reader.prepare("SELECT id FROM entries").unwrap();
let mut rows = stmt.query([]).unwrap();
let _first = rows.next().unwrap();
assert!(ix.remove("mem-a-1").unwrap());
assert!(
matches!(ix.vacuum(), Err(Error::Held)),
"a vacuum that could not checkpoint the log says so"
);
drop(rows);
drop(stmt);
ix.vacuum().unwrap();
drop(reader);
drop(ix);
let bytes = index_bytes(dir.path());
assert!(!bytes.windows(marker.len()).any(|w| w == marker));
}
}