1use 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#[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 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
62pub 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 fn list(
70 &self,
71 after: Option<Cursor>,
72 limit: u32,
73 filter: &MemoFilter,
74 ) -> Result<Vec<IndexRecord>>;
75 fn export_since(&self, since: Option<OffsetDateTime>) -> Result<Vec<IndexRecord>>;
77 fn count(&self) -> Result<u64>;
78 fn clear(&self) -> Result<()>;
79}
80
81pub struct RedbIndex {
83 db: redb::Database,
84}
85
86impl RedbIndex {
87 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 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
240fn 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 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 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}