Skip to main content

oximemo_core/store/
index.rs

1//! Metadata index backed by `redb` (§5.1).
2//!
3//! Two tables maintain the index:
4//! - `by_id`: `id (16B)` → serialized [`IndexRecord`]
5//! - `by_sort`: `sort_key (24B)` → `id (16B)`, where `sort_key` encodes
6//!   `(updated_at, id)` such that ascending byte order is **newest-first**.
7//!
8//! Newest-first ordering is achieved by storing the bitwise complement of the
9//! timestamp and id bytes: larger natural values map to smaller encoded keys,
10//! so a plain ascending range scan yields notes newest-first, and "older than
11//! cursor" is simply `range((Excluded(cursor_key), Unbounded))`. This needs no
12//! reverse iteration and bounds pagination work to the page size.
13
14use redb::{ReadableTable, ReadableTableMetadata};
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17
18use crate::error::Result;
19use crate::memo::{Cursor, MemoFilter, MemoHash, MemoId, MemoSummary};
20
21const BY_ID: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("by_id");
22const BY_SORT: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("by_sort");
23
24/// A note's indexed metadata (no body). Stored as the index value.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct IndexRecord {
27    pub id: MemoId,
28    #[serde(with = "time::serde::rfc3339")]
29    pub created_at: OffsetDateTime,
30    #[serde(with = "time::serde::rfc3339")]
31    pub updated_at: OffsetDateTime,
32    pub hash: MemoHash,
33    #[serde(default, alias = "pinned")]
34    pub favorite: bool,
35    #[serde(default = "crate::memo::default_category")]
36    pub category: String,
37    #[serde(default)]
38    pub tags: Vec<String>,
39    pub deleted: bool,
40    #[serde(default, with = "time::serde::rfc3339::option")]
41    pub deleted_at: Option<OffsetDateTime>,
42    /// Cached card preview (derived from body) so listings avoid file reads.
43    pub preview: String,
44}
45
46impl IndexRecord {
47    pub fn to_summary(&self) -> MemoSummary {
48        MemoSummary {
49            id: self.id,
50            created_at: self.created_at,
51            updated_at: self.updated_at,
52            hash: self.hash.clone(),
53            favorite: self.favorite,
54            category: self.category.clone(),
55            tags: self.tags.clone(),
56            preview: self.preview.clone(),
57            deleted: self.deleted,
58        }
59    }
60}
61
62/// Swappable storage boundary (§5.1). A future SQLite+FTS5 backend implements
63/// this same trait.
64pub trait MemoIndex: Send + Sync {
65    fn upsert(&self, rec: &IndexRecord) -> Result<()>;
66    fn remove(&self, id: MemoId) -> Result<()>;
67    fn get(&self, id: MemoId) -> Result<Option<IndexRecord>>;
68    /// Cursor-paginated, newest-first listing with in-memory filter.
69    fn list(
70        &self,
71        after: Option<Cursor>,
72        limit: u32,
73        filter: &MemoFilter,
74    ) -> Result<Vec<IndexRecord>>;
75    /// Notes with `updated_at >= since` (newest-first). `None` = all notes.
76    fn export_since(&self, since: Option<OffsetDateTime>) -> Result<Vec<IndexRecord>>;
77    fn count(&self) -> Result<u64>;
78    fn clear(&self) -> Result<()>;
79}
80
81/// `redb`-backed [`MemoIndex`].
82pub struct RedbIndex {
83    db: redb::Database,
84}
85
86impl RedbIndex {
87    /// Open (creating) the index database. Caller is responsible for the
88    /// cross-process advisory lock (§5.7) around open + use.
89    pub fn open(path: &std::path::Path) -> Result<Self> {
90        if let Some(parent) = path.parent() {
91            std::fs::create_dir_all(parent)?;
92        }
93        let db = redb::Database::create(path)?;
94        let tx = db.begin_write()?;
95        {
96            let _ = tx.open_table(BY_ID)?;
97            let _ = tx.open_table(BY_SORT)?;
98        }
99        tx.commit()?;
100        Ok(Self { db })
101    }
102}
103
104impl MemoIndex for RedbIndex {
105    fn upsert(&self, rec: &IndexRecord) -> Result<()> {
106        let uuid = rec.id.as_uuid();
107        let id_slice: &[u8] = uuid.as_bytes();
108        let sort_key = encode_sort(rec.updated_at, rec.id);
109        let value = serde_json::to_vec(rec)?;
110
111        let tx = self.db.begin_write()?;
112        {
113            let mut by_id = tx.open_table(BY_ID)?;
114            let mut by_sort = tx.open_table(BY_SORT)?;
115            // A previous sort key may exist for this id (updated_at may have
116            // changed); remove it before inserting the fresh one.
117            if let Some(prev) = by_id.get(id_slice)? {
118                let prev_rec: IndexRecord = serde_json::from_slice(prev.value())?;
119                let prev_key = encode_sort(prev_rec.updated_at, prev_rec.id);
120                by_sort.remove(&prev_key[..])?;
121            }
122            by_id.insert(id_slice, value.as_slice())?;
123            by_sort.insert(sort_key.as_slice(), id_slice)?;
124        }
125        tx.commit()?;
126        Ok(())
127    }
128
129    fn remove(&self, id: MemoId) -> Result<()> {
130        let uuid = id.as_uuid();
131        let id_slice: &[u8] = uuid.as_bytes();
132        let tx = self.db.begin_write()?;
133        {
134            let mut by_id = tx.open_table(BY_ID)?;
135            let mut by_sort = tx.open_table(BY_SORT)?;
136            if let Some(prev) = by_id.get(id_slice)? {
137                let prev_rec: IndexRecord = serde_json::from_slice(prev.value())?;
138                let prev_key = encode_sort(prev_rec.updated_at, prev_rec.id);
139                by_sort.remove(&prev_key[..])?;
140            }
141            by_id.remove(id_slice)?;
142        }
143        tx.commit()?;
144        Ok(())
145    }
146
147    fn get(&self, id: MemoId) -> Result<Option<IndexRecord>> {
148        let uuid = id.as_uuid();
149        let id_slice: &[u8] = uuid.as_bytes();
150        let tx = self.db.begin_read()?;
151        let table = tx.open_table(BY_ID)?;
152        Ok(match table.get(id_slice)? {
153            Some(g) => Some(serde_json::from_slice(g.value())?),
154            None => None,
155        })
156    }
157
158    fn list(
159        &self,
160        after: Option<Cursor>,
161        limit: u32,
162        filter: &MemoFilter,
163    ) -> Result<Vec<IndexRecord>> {
164        let tx = self.db.begin_read()?;
165        let by_sort = tx.open_table(BY_SORT)?;
166        let by_id = tx.open_table(BY_ID)?;
167
168        let mut out = Vec::new();
169        let iter = match after {
170            None => by_sort.iter()?,
171            Some(c) => {
172                let start = encode_sort(c.updated_at, c.id);
173                let bounds: (std::ops::Bound<&[u8]>, std::ops::Bound<&[u8]>) = (
174                    std::ops::Bound::Excluded(start.as_slice()),
175                    std::ops::Bound::Unbounded,
176                );
177                by_sort.range::<&[u8]>(bounds)?
178            }
179        };
180
181        for item in iter {
182            let (_sort_key, id_guard) = item?;
183            let id_slice: &[u8] = id_guard.value();
184            let rec: IndexRecord = match by_id.get(id_slice)? {
185                Some(g) => serde_json::from_slice(g.value())?,
186                None => continue,
187            };
188            let summary = rec.to_summary();
189            if !filter.matches(&summary) {
190                continue;
191            }
192            out.push(rec);
193            if out.len() >= limit as usize {
194                break;
195            }
196        }
197        Ok(out)
198    }
199
200    fn export_since(&self, since: Option<OffsetDateTime>) -> Result<Vec<IndexRecord>> {
201        let tx = self.db.begin_read()?;
202        let by_sort = tx.open_table(BY_SORT)?;
203        let by_id = tx.open_table(BY_ID)?;
204
205        let mut out = Vec::new();
206        for item in by_sort.iter()? {
207            let (_sort_key, id_guard) = item?;
208            let id_slice: &[u8] = id_guard.value();
209            let rec: IndexRecord = match by_id.get(id_slice)? {
210                Some(g) => serde_json::from_slice(g.value())?,
211                None => continue,
212            };
213            match since {
214                Some(t) if rec.updated_at < t => break,
215                _ => out.push(rec),
216            }
217        }
218        Ok(out)
219    }
220
221    fn count(&self) -> Result<u64> {
222        let tx = self.db.begin_read()?;
223        let table = tx.open_table(BY_ID)?;
224        Ok(table.len()?)
225    }
226
227    fn clear(&self) -> Result<()> {
228        let tx = self.db.begin_write()?;
229        {
230            tx.delete_table(BY_ID)?;
231            tx.delete_table(BY_SORT)?;
232            let _ = tx.open_table(BY_ID)?;
233            let _ = tx.open_table(BY_SORT)?;
234        }
235        tx.commit()?;
236        Ok(())
237    }
238}
239
240/// Encode a sort key: 8 bytes (inverted timestamp) ++ 16 bytes (inverted id),
241/// so ascending byte order is newest-first.
242fn encode_sort(updated_at: OffsetDateTime, id: MemoId) -> [u8; 24] {
243    let mut out = [0u8; 24];
244    let nanos = updated_at.unix_timestamp_nanos();
245    let clamped = nanos.clamp(0, i64::MAX as i128) as i64;
246    let inv_ts = i64::MAX - clamped;
247    out[..8].copy_from_slice(&inv_ts.to_be_bytes());
248    let uuid = id.as_uuid();
249    let id_bytes = uuid.as_bytes();
250    for (i, b) in id_bytes.iter().enumerate() {
251        out[8 + i] = !b;
252    }
253    out
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use tempfile::TempDir;
260
261    fn rec(id: MemoId, ts: OffsetDateTime) -> IndexRecord {
262        IndexRecord {
263            id,
264            created_at: ts,
265            updated_at: ts,
266            hash: MemoHash::new("deadbeef"),
267            favorite: false,
268            category: "inbox".to_string(),
269            tags: vec![],
270            deleted: false,
271            deleted_at: None,
272            preview: String::new(),
273        }
274    }
275
276    fn open_tmp() -> (TempDir, RedbIndex) {
277        let dir = TempDir::new().unwrap();
278        let idx = RedbIndex::open(&dir.path().join("meta.redb")).unwrap();
279        (dir, idx)
280    }
281
282    #[test]
283    fn upsert_get_remove() {
284        let (_t, idx) = open_tmp();
285        let id = MemoId::now();
286        let r = rec(id, OffsetDateTime::now_utc());
287        idx.upsert(&r).unwrap();
288        assert_eq!(idx.get(id).unwrap().unwrap().id, id);
289        idx.remove(id).unwrap();
290        assert!(idx.get(id).unwrap().is_none());
291    }
292    #[test]
293    fn list_is_newest_first_with_pagination() {
294        let (_t, idx) = open_tmp();
295        let base = OffsetDateTime::now_utc();
296        let ids: Vec<MemoId> = (0..5).map(|_| MemoId::now()).collect();
297        for (i, id) in ids.iter().enumerate() {
298            idx.upsert(&rec(*id, base + time::Duration::seconds(i as i64)))
299                .unwrap();
300        }
301        let page = idx.list(None, 3, &MemoFilter::default()).unwrap();
302        assert_eq!(page.len(), 3);
303        // newest first: last-inserted (largest ts) comes first
304        assert_eq!(page[0].id, *ids.last().unwrap());
305        let cursor = Cursor {
306            updated_at: page[2].updated_at,
307            id: page[2].id,
308        };
309        let next = idx.list(Some(cursor), 3, &MemoFilter::default()).unwrap();
310        assert_eq!(next.len(), 2);
311    }
312    #[test]
313    fn deserializes_legacy_pinned_and_missing_favorite() {
314        // Pre-release vaults stored the favorite flag as `pinned`; redb index
315        // records were serialized as JSON with a `pinned` field, which made
316        // serde reject them with "missing field `favorite`" (the save-time
317        // crash). The #[serde(default, alias = "pinned")] on IndexRecord makes
318        // old records load: the alias maps `pinned`, and a missing field
319        // defaults to false.
320        let legacy_pinned = r#"{"id":"019fa927-a897-7e12-9102-8a8c7ebbb594","created_at":"2026-07-01T00:00:00Z","updated_at":"2026-07-01T00:00:00Z","hash":"b3:abc","pinned":true,"category":"inbox","tags":[],"deleted":false,"deleted_at":null,"preview":""}"#;
321        let rec: IndexRecord = serde_json::from_str(legacy_pinned).unwrap();
322        assert!(rec.favorite);
323
324        let no_flag = r#"{"id":"019fa927-a897-7e12-9102-8a8c7ebbb594","created_at":"2026-07-01T00:00:00Z","updated_at":"2026-07-01T00:00:00Z","hash":"b3:abc","category":"inbox","tags":[],"deleted":false,"deleted_at":null,"preview":""}"#;
325        let rec: IndexRecord = serde_json::from_str(no_flag).unwrap();
326        assert!(!rec.favorite);
327    }
328}