use redb::{ReadableTable, ReadableTableMetadata};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::error::Result;
use crate::memo::{Cursor, MemoFilter, MemoHash, MemoId, MemoSummary};
const BY_ID: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("by_id");
const BY_SORT: redb::TableDefinition<&[u8], &[u8]> = redb::TableDefinition::new("by_sort");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexRecord {
pub id: MemoId,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
pub hash: MemoHash,
#[serde(default, alias = "pinned")]
pub favorite: bool,
#[serde(default = "crate::memo::default_category")]
pub category: String,
#[serde(default)]
pub tags: Vec<String>,
pub deleted: bool,
#[serde(default, with = "time::serde::rfc3339::option")]
pub deleted_at: Option<OffsetDateTime>,
pub preview: String,
}
impl IndexRecord {
pub fn to_summary(&self) -> MemoSummary {
MemoSummary {
id: self.id,
created_at: self.created_at,
updated_at: self.updated_at,
hash: self.hash.clone(),
favorite: self.favorite,
category: self.category.clone(),
tags: self.tags.clone(),
preview: self.preview.clone(),
deleted: self.deleted,
}
}
}
pub trait MemoIndex: Send + Sync {
fn upsert(&self, rec: &IndexRecord) -> Result<()>;
fn remove(&self, id: MemoId) -> Result<()>;
fn get(&self, id: MemoId) -> Result<Option<IndexRecord>>;
fn list(
&self,
after: Option<Cursor>,
limit: u32,
filter: &MemoFilter,
) -> Result<Vec<IndexRecord>>;
fn export_since(&self, since: Option<OffsetDateTime>) -> Result<Vec<IndexRecord>>;
fn count(&self) -> Result<u64>;
fn clear(&self) -> Result<()>;
}
pub struct RedbIndex {
db: redb::Database,
}
impl RedbIndex {
pub fn open(path: &std::path::Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let db = redb::Database::create(path)?;
let tx = db.begin_write()?;
{
let _ = tx.open_table(BY_ID)?;
let _ = tx.open_table(BY_SORT)?;
}
tx.commit()?;
Ok(Self { db })
}
}
impl MemoIndex for RedbIndex {
fn upsert(&self, rec: &IndexRecord) -> Result<()> {
let uuid = rec.id.as_uuid();
let id_slice: &[u8] = uuid.as_bytes();
let sort_key = encode_sort(rec.updated_at, rec.id);
let value = serde_json::to_vec(rec)?;
let tx = self.db.begin_write()?;
{
let mut by_id = tx.open_table(BY_ID)?;
let mut by_sort = tx.open_table(BY_SORT)?;
if let Some(prev) = by_id.get(id_slice)? {
let prev_rec: IndexRecord = serde_json::from_slice(prev.value())?;
let prev_key = encode_sort(prev_rec.updated_at, prev_rec.id);
by_sort.remove(&prev_key[..])?;
}
by_id.insert(id_slice, value.as_slice())?;
by_sort.insert(sort_key.as_slice(), id_slice)?;
}
tx.commit()?;
Ok(())
}
fn remove(&self, id: MemoId) -> Result<()> {
let uuid = id.as_uuid();
let id_slice: &[u8] = uuid.as_bytes();
let tx = self.db.begin_write()?;
{
let mut by_id = tx.open_table(BY_ID)?;
let mut by_sort = tx.open_table(BY_SORT)?;
if let Some(prev) = by_id.get(id_slice)? {
let prev_rec: IndexRecord = serde_json::from_slice(prev.value())?;
let prev_key = encode_sort(prev_rec.updated_at, prev_rec.id);
by_sort.remove(&prev_key[..])?;
}
by_id.remove(id_slice)?;
}
tx.commit()?;
Ok(())
}
fn get(&self, id: MemoId) -> Result<Option<IndexRecord>> {
let uuid = id.as_uuid();
let id_slice: &[u8] = uuid.as_bytes();
let tx = self.db.begin_read()?;
let table = tx.open_table(BY_ID)?;
Ok(match table.get(id_slice)? {
Some(g) => Some(serde_json::from_slice(g.value())?),
None => None,
})
}
fn list(
&self,
after: Option<Cursor>,
limit: u32,
filter: &MemoFilter,
) -> Result<Vec<IndexRecord>> {
let tx = self.db.begin_read()?;
let by_sort = tx.open_table(BY_SORT)?;
let by_id = tx.open_table(BY_ID)?;
let mut out = Vec::new();
let iter = match after {
None => by_sort.iter()?,
Some(c) => {
let start = encode_sort(c.updated_at, c.id);
let bounds: (std::ops::Bound<&[u8]>, std::ops::Bound<&[u8]>) = (
std::ops::Bound::Excluded(start.as_slice()),
std::ops::Bound::Unbounded,
);
by_sort.range::<&[u8]>(bounds)?
}
};
for item in iter {
let (_sort_key, id_guard) = item?;
let id_slice: &[u8] = id_guard.value();
let rec: IndexRecord = match by_id.get(id_slice)? {
Some(g) => serde_json::from_slice(g.value())?,
None => continue,
};
let summary = rec.to_summary();
if !filter.matches(&summary) {
continue;
}
out.push(rec);
if out.len() >= limit as usize {
break;
}
}
Ok(out)
}
fn export_since(&self, since: Option<OffsetDateTime>) -> Result<Vec<IndexRecord>> {
let tx = self.db.begin_read()?;
let by_sort = tx.open_table(BY_SORT)?;
let by_id = tx.open_table(BY_ID)?;
let mut out = Vec::new();
for item in by_sort.iter()? {
let (_sort_key, id_guard) = item?;
let id_slice: &[u8] = id_guard.value();
let rec: IndexRecord = match by_id.get(id_slice)? {
Some(g) => serde_json::from_slice(g.value())?,
None => continue,
};
match since {
Some(t) if rec.updated_at < t => break,
_ => out.push(rec),
}
}
Ok(out)
}
fn count(&self) -> Result<u64> {
let tx = self.db.begin_read()?;
let table = tx.open_table(BY_ID)?;
Ok(table.len()?)
}
fn clear(&self) -> Result<()> {
let tx = self.db.begin_write()?;
{
tx.delete_table(BY_ID)?;
tx.delete_table(BY_SORT)?;
let _ = tx.open_table(BY_ID)?;
let _ = tx.open_table(BY_SORT)?;
}
tx.commit()?;
Ok(())
}
}
fn encode_sort(updated_at: OffsetDateTime, id: MemoId) -> [u8; 24] {
let mut out = [0u8; 24];
let nanos = updated_at.unix_timestamp_nanos();
let clamped = nanos.clamp(0, i64::MAX as i128) as i64;
let inv_ts = i64::MAX - clamped;
out[..8].copy_from_slice(&inv_ts.to_be_bytes());
let uuid = id.as_uuid();
let id_bytes = uuid.as_bytes();
for (i, b) in id_bytes.iter().enumerate() {
out[8 + i] = !b;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn rec(id: MemoId, ts: OffsetDateTime) -> IndexRecord {
IndexRecord {
id,
created_at: ts,
updated_at: ts,
hash: MemoHash::new("deadbeef"),
favorite: false,
category: "inbox".to_string(),
tags: vec![],
deleted: false,
deleted_at: None,
preview: String::new(),
}
}
fn open_tmp() -> (TempDir, RedbIndex) {
let dir = TempDir::new().unwrap();
let idx = RedbIndex::open(&dir.path().join("meta.redb")).unwrap();
(dir, idx)
}
#[test]
fn upsert_get_remove() {
let (_t, idx) = open_tmp();
let id = MemoId::now();
let r = rec(id, OffsetDateTime::now_utc());
idx.upsert(&r).unwrap();
assert_eq!(idx.get(id).unwrap().unwrap().id, id);
idx.remove(id).unwrap();
assert!(idx.get(id).unwrap().is_none());
}
#[test]
fn list_is_newest_first_with_pagination() {
let (_t, idx) = open_tmp();
let base = OffsetDateTime::now_utc();
let ids: Vec<MemoId> = (0..5).map(|_| MemoId::now()).collect();
for (i, id) in ids.iter().enumerate() {
idx.upsert(&rec(*id, base + time::Duration::seconds(i as i64)))
.unwrap();
}
let page = idx.list(None, 3, &MemoFilter::default()).unwrap();
assert_eq!(page.len(), 3);
assert_eq!(page[0].id, *ids.last().unwrap());
let cursor = Cursor {
updated_at: page[2].updated_at,
id: page[2].id,
};
let next = idx.list(Some(cursor), 3, &MemoFilter::default()).unwrap();
assert_eq!(next.len(), 2);
}
#[test]
fn deserializes_legacy_pinned_and_missing_favorite() {
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":""}"#;
let rec: IndexRecord = serde_json::from_str(legacy_pinned).unwrap();
assert!(rec.favorite);
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":""}"#;
let rec: IndexRecord = serde_json::from_str(no_flag).unwrap();
assert!(!rec.favorite);
}
}