1mod error;
2mod maintenance;
3mod sqlite;
4
5pub use self::error::DatabaseError;
6pub use self::maintenance::DatabaseMaintenance;
7use self::sqlite::SqliteDatabase;
8use crate::metrics::MetricsDatabase;
9use crate::types::{NoteId, NoteTag, StoredNote};
10
11#[async_trait::async_trait]
13pub trait DatabaseBackend: Send + Sync {
14 async fn connect(
16 config: DatabaseConfig,
17 metrics: MetricsDatabase,
18 ) -> Result<Self, DatabaseError>
19 where
20 Self: Sized;
21
22 async fn store_note(&self, note: &StoredNote) -> Result<(), DatabaseError>;
24
25 async fn fetch_notes(
27 &self,
28 tag: NoteTag,
29 cursor: u64,
30 ) -> Result<Vec<StoredNote>, DatabaseError>;
31
32 async fn fetch_notes_by_tags(
38 &self,
39 tags: &[NoteTag],
40 cursor: u64,
41 ) -> Result<Vec<StoredNote>, DatabaseError>;
42
43 async fn get_stats(&self) -> Result<(u64, u64), DatabaseError>;
45
46 async fn cleanup_old_notes(&self, retention_days: u32) -> Result<u64, DatabaseError>;
48
49 async fn note_exists(&self, note_id: NoteId) -> Result<bool, DatabaseError>;
51}
52
53pub struct Database {
55 backend: Box<dyn DatabaseBackend>,
56}
57
58#[derive(Debug, Clone)]
60pub struct DatabaseConfig {
61 pub url: String,
63 pub retention_days: u32,
65}
66
67impl Default for DatabaseConfig {
68 fn default() -> Self {
69 Self {
70 url: ":memory:".to_string(),
71 retention_days: 30,
72 }
73 }
74}
75
76impl Database {
77 pub async fn connect(
79 config: DatabaseConfig,
80 metrics: MetricsDatabase,
81 ) -> Result<Self, DatabaseError> {
82 let backend = SqliteDatabase::connect(config, metrics).await?;
83 Ok(Self { backend: Box::new(backend) })
84 }
85
86 pub async fn store_note(&self, note: &StoredNote) -> Result<(), DatabaseError> {
88 self.backend.store_note(note).await?;
89 Ok(())
90 }
91
92 pub async fn fetch_notes(
94 &self,
95 tag: NoteTag,
96 cursor: u64,
97 ) -> Result<Vec<StoredNote>, DatabaseError> {
98 self.backend.fetch_notes(tag, cursor).await
99 }
100
101 pub async fn fetch_notes_by_tags(
103 &self,
104 tags: &[NoteTag],
105 cursor: u64,
106 ) -> Result<Vec<StoredNote>, DatabaseError> {
107 self.backend.fetch_notes_by_tags(tags, cursor).await
108 }
109
110 pub async fn get_stats(&self) -> Result<(u64, u64), DatabaseError> {
112 self.backend.get_stats().await
113 }
114
115 pub async fn cleanup_old_notes(&self, retention_days: u32) -> Result<u64, DatabaseError> {
117 self.backend.cleanup_old_notes(retention_days).await
118 }
119
120 pub async fn note_exists(&self, note_id: NoteId) -> Result<bool, DatabaseError> {
122 self.backend.note_exists(note_id).await
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use chrono::Utc;
129
130 use super::*;
131 use crate::metrics::Metrics;
132 use crate::test_utils::{TAG_LOCAL_ANY, test_note_header, test_note_header_with_tag};
133
134 #[tokio::test]
135 async fn test_sqlite_database() {
136 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
137 .await
138 .unwrap();
139
140 let note = StoredNote {
141 header: test_note_header(),
142 details: vec![1, 2, 3, 4],
143 created_at: Utc::now(),
144 seq: 0, };
146
147 db.store_note(¬e).await.unwrap();
148
149 let fetched_notes = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
151 assert_eq!(fetched_notes.len(), 1);
152 assert_eq!(fetched_notes[0].header.id(), note.header.id());
153 assert!(fetched_notes[0].seq > 0);
154
155 assert!(db.note_exists(note.header.id()).await.unwrap());
157
158 let (total_notes, total_tags) = db.get_stats().await.unwrap();
160 assert_eq!(total_notes, 1);
161 assert_eq!(total_tags, 1);
162 }
163
164 #[tokio::test]
165 async fn test_seq_assigned_monotonically_in_insert_order() {
166 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
167 .await
168 .unwrap();
169
170 let first = StoredNote {
171 header: test_note_header(),
172 details: vec![1],
173 created_at: Utc::now(),
174 seq: 0,
175 };
176 db.store_note(&first).await.unwrap();
177
178 let second = StoredNote {
179 header: test_note_header(),
180 details: vec![2],
181 created_at: Utc::now(),
182 seq: 0,
183 };
184 db.store_note(&second).await.unwrap();
185
186 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
188 assert_eq!(fetched.len(), 2);
189 assert!(
190 fetched[0].seq < fetched[1].seq,
191 "expected monotonic seq; got {} then {}",
192 fetched[0].seq,
193 fetched[1].seq
194 );
195 assert_eq!(fetched[0].details, vec![1]);
196 assert_eq!(fetched[1].details, vec![2]);
197
198 let mid_cursor = u64::try_from(fetched[0].seq).expect("seq is non-negative");
200 let after_first = db.fetch_notes(TAG_LOCAL_ANY.into(), mid_cursor).await.unwrap();
201 assert_eq!(after_first.len(), 1);
202 assert_eq!(after_first[0].details, vec![2]);
203 }
204
205 #[tokio::test]
206 async fn test_concurrent_store_fetch_sees_all_rows() {
207 use std::sync::Arc;
216
217 use tokio::task::JoinSet;
218
219 const TAG_A: u32 = 0x3d9c_0000;
220 const TAG_B: u32 = 0x47ac_0000;
221
222 let db = Arc::new(
223 Database::connect(DatabaseConfig::default(), Metrics::default().db)
224 .await
225 .unwrap(),
226 );
227
228 let mut writers = JoinSet::new();
231 for i in 0..40u32 {
232 let db = db.clone();
233 writers.spawn(async move {
234 let tag = if i % 2 == 0 { TAG_A } else { TAG_B };
235 db.store_note(&StoredNote {
236 header: test_note_header_with_tag(tag),
237 details: vec![i as u8],
238 created_at: Utc::now(),
239 seq: 0,
240 })
241 .await
242 .unwrap();
243 });
244 }
245 while writers.join_next().await.is_some() {}
246
247 let fetched_a = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
248 let fetched_b = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
249 assert_eq!(
250 fetched_a.len() + fetched_b.len(),
251 40,
252 "all 40 concurrent writes should be visible"
253 );
254
255 let (total, _) = db.get_stats().await.unwrap();
256 assert_eq!(total, 40, "stats should reflect all 40 rows");
257 }
258
259 #[tokio::test]
260 async fn test_fetch_notes_seq_cursor_filtering() {
261 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
262 .await
263 .unwrap();
264
265 let note = StoredNote {
266 header: test_note_header(),
267 details: vec![1, 2, 3, 4],
268 created_at: Utc::now(),
269 seq: 0, };
271
272 db.store_note(¬e).await.unwrap();
273
274 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
276 assert_eq!(fetched.len(), 1);
277 let stored_seq = fetched[0].seq;
278 assert!(stored_seq > 0, "expected seq > 0, got {stored_seq}");
279
280 let cursor = u64::try_from(stored_seq).expect("seq is non-negative");
282 let after = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
283 assert_eq!(after.len(), 0);
284 }
285
286 #[tokio::test]
295 async fn test_seq_cursor_survives_identical_created_at() {
296 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
297 .await
298 .unwrap();
299
300 let t = Utc::now();
301 let note1 = StoredNote {
302 header: test_note_header(),
303 details: vec![1],
304 created_at: t,
305 seq: 0,
306 };
307 db.store_note(¬e1).await.unwrap();
308
309 let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
311 assert_eq!(first.len(), 1);
312 let cursor = u64::try_from(first[0].seq).expect("seq is non-negative");
313
314 let note2 = StoredNote {
316 header: test_note_header(),
317 details: vec![2],
318 created_at: t,
319 seq: 0,
320 };
321 db.store_note(¬e2).await.unwrap();
322
323 let second = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
326 assert_eq!(
327 second.len(),
328 1,
329 "seq cursor must return note2 despite identical created_at; got {} notes",
330 second.len()
331 );
332 assert_eq!(second[0].details, vec![2]);
333 }
334
335 #[tokio::test]
347 async fn test_multi_tag_single_snapshot_vs_per_tag_loop() {
348 use std::convert::TryFrom;
349
350 const TAG_A: u32 = 0x3d9c_0000;
351 const TAG_B: u32 = 0x47ac_0000;
352
353 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
354 .await
355 .unwrap();
356
357 db.store_note(&StoredNote {
359 header: test_note_header_with_tag(TAG_A),
360 details: vec![1],
361 created_at: Utc::now(),
362 seq: 0,
363 })
364 .await
365 .unwrap();
366
367 let a_result = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
370 assert_eq!(a_result.len(), 1);
371
372 db.store_note(&StoredNote {
377 header: test_note_header_with_tag(TAG_A),
378 details: vec![2],
379 created_at: Utc::now(),
380 seq: 0,
381 })
382 .await
383 .unwrap();
384 db.store_note(&StoredNote {
385 header: test_note_header_with_tag(TAG_B),
386 details: vec![3],
387 created_at: Utc::now(),
388 seq: 0,
389 })
390 .await
391 .unwrap();
392
393 let b_result = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
395 assert_eq!(b_result.len(), 1);
396
397 let rcursor: u64 = a_result
400 .iter()
401 .chain(b_result.iter())
402 .map(|n| u64::try_from(n.seq).unwrap())
403 .max()
404 .unwrap_or(0);
405
406 let retry_a = db.fetch_notes(TAG_A.into(), rcursor).await.unwrap();
408 let retry_b = db.fetch_notes(TAG_B.into(), rcursor).await.unwrap();
409
410 let per_tag_visible = a_result.len() + b_result.len() + retry_a.len() + retry_b.len();
413 assert_eq!(
414 per_tag_visible, 2,
415 "per-tag loop must lose the interleaved tag-A insert; visible = {per_tag_visible}"
416 );
417 assert!(
418 !retry_a.iter().any(|n| n.details == vec![2]),
419 "the interleaved tag-A insert with details=[2] must be unreachable to the per-tag loop"
420 );
421
422 let snapshot = db.fetch_notes_by_tags(&[TAG_A.into(), TAG_B.into()], 0).await.unwrap();
424 assert_eq!(
425 snapshot.len(),
426 3,
427 "fetch_notes_by_tags must return all 3 notes in a single consistent snapshot; got {}",
428 snapshot.len()
429 );
430 assert!(
431 snapshot.iter().any(|n| n.details == vec![2]),
432 "the interleaved tag-A insert MUST be visible via the single-snapshot query"
433 );
434 }
435
436 #[tokio::test]
441 async fn test_fetch_notes_resets_legacy_cursor() {
442 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
443 .await
444 .unwrap();
445
446 let note = StoredNote {
447 header: test_note_header(),
448 details: vec![1, 2, 3, 4],
449 created_at: Utc::now(),
450 seq: 0,
451 };
452 db.store_note(¬e).await.unwrap();
453
454 let legacy_cursor: u64 = 1_760_000_000_000_000;
457 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), legacy_cursor).await.unwrap();
458 assert_eq!(
459 fetched.len(),
460 1,
461 "legacy microsecond cursor should be reset to 0, returning the note"
462 );
463
464 let normal_cursor: u64 = 1_000;
466 let empty = db.fetch_notes(TAG_LOCAL_ANY.into(), normal_cursor).await.unwrap();
467 assert_eq!(empty.len(), 0, "normal cursor > seq should filter correctly");
468 }
469
470 #[tokio::test]
474 async fn test_fetch_notes_paginates_at_batch_limit() {
475 use crate::database::sqlite::FETCH_NOTES_BATCH_SIZE;
476
477 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
478 .await
479 .unwrap();
480
481 let extra: usize = 7;
483 let total = usize::try_from(FETCH_NOTES_BATCH_SIZE).unwrap() + extra;
484 for i in 0..total {
485 db.store_note(&StoredNote {
486 header: test_note_header(),
487 details: vec![(i % 256) as u8],
488 created_at: Utc::now(),
489 seq: 0,
490 })
491 .await
492 .unwrap();
493 }
494
495 let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
497 assert_eq!(
498 i64::try_from(first.len()).unwrap(),
499 FETCH_NOTES_BATCH_SIZE,
500 "first batch must be capped at FETCH_NOTES_BATCH_SIZE"
501 );
502
503 let advanced: u64 = first.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap();
505 let second = db.fetch_notes(TAG_LOCAL_ANY.into(), advanced).await.unwrap();
506 assert_eq!(second.len(), extra, "second batch must contain the remaining {extra} rows");
507
508 let third_cursor: u64 =
510 second.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap_or(advanced);
511 let third = db.fetch_notes(TAG_LOCAL_ANY.into(), third_cursor).await.unwrap();
512 assert_eq!(third.len(), 0, "drained");
513
514 let (total_stats, _) = db.get_stats().await.unwrap();
516 assert_eq!(usize::try_from(total_stats).unwrap(), total);
517 }
518}