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, after_block_num: None,
146 };
147
148 db.store_note(¬e).await.unwrap();
149
150 let fetched_notes = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
152 assert_eq!(fetched_notes.len(), 1);
153 assert_eq!(fetched_notes[0].header.id(), note.header.id());
154 assert!(fetched_notes[0].seq > 0);
155
156 assert!(db.note_exists(note.header.id()).await.unwrap());
158
159 let (total_notes, total_tags) = db.get_stats().await.unwrap();
161 assert_eq!(total_notes, 1);
162 assert_eq!(total_tags, 1);
163 }
164
165 #[tokio::test]
166 async fn test_seq_assigned_monotonically_in_insert_order() {
167 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
168 .await
169 .unwrap();
170
171 let first = StoredNote {
172 header: test_note_header(),
173 details: vec![1],
174 created_at: Utc::now(),
175 seq: 0,
176 after_block_num: None,
177 };
178 db.store_note(&first).await.unwrap();
179
180 let second = StoredNote {
181 header: test_note_header(),
182 details: vec![2],
183 created_at: Utc::now(),
184 seq: 0,
185 after_block_num: None,
186 };
187 db.store_note(&second).await.unwrap();
188
189 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
191 assert_eq!(fetched.len(), 2);
192 assert!(
193 fetched[0].seq < fetched[1].seq,
194 "expected monotonic seq; got {} then {}",
195 fetched[0].seq,
196 fetched[1].seq
197 );
198 assert_eq!(fetched[0].details, vec![1]);
199 assert_eq!(fetched[1].details, vec![2]);
200
201 let mid_cursor = u64::try_from(fetched[0].seq).expect("seq is non-negative");
203 let after_first = db.fetch_notes(TAG_LOCAL_ANY.into(), mid_cursor).await.unwrap();
204 assert_eq!(after_first.len(), 1);
205 assert_eq!(after_first[0].details, vec![2]);
206 }
207
208 #[tokio::test]
209 async fn test_concurrent_store_fetch_sees_all_rows() {
210 use std::sync::Arc;
219
220 use tokio::task::JoinSet;
221
222 const TAG_A: u32 = 0x3d9c_0000;
223 const TAG_B: u32 = 0x47ac_0000;
224
225 let db = Arc::new(
226 Database::connect(DatabaseConfig::default(), Metrics::default().db)
227 .await
228 .unwrap(),
229 );
230
231 let mut writers = JoinSet::new();
234 for i in 0..40u32 {
235 let db = db.clone();
236 writers.spawn(async move {
237 let tag = if i % 2 == 0 { TAG_A } else { TAG_B };
238 db.store_note(&StoredNote {
239 header: test_note_header_with_tag(tag),
240 details: vec![i as u8],
241 created_at: Utc::now(),
242 seq: 0,
243 after_block_num: None,
244 })
245 .await
246 .unwrap();
247 });
248 }
249 while writers.join_next().await.is_some() {}
250
251 let fetched_a = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
252 let fetched_b = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
253 assert_eq!(
254 fetched_a.len() + fetched_b.len(),
255 40,
256 "all 40 concurrent writes should be visible"
257 );
258
259 let (total, _) = db.get_stats().await.unwrap();
260 assert_eq!(total, 40, "stats should reflect all 40 rows");
261 }
262
263 #[tokio::test]
264 async fn test_fetch_notes_seq_cursor_filtering() {
265 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
266 .await
267 .unwrap();
268
269 let note = StoredNote {
270 header: test_note_header(),
271 details: vec![1, 2, 3, 4],
272 created_at: Utc::now(),
273 seq: 0, after_block_num: None,
275 };
276
277 db.store_note(¬e).await.unwrap();
278
279 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
281 assert_eq!(fetched.len(), 1);
282 let stored_seq = fetched[0].seq;
283 assert!(stored_seq > 0, "expected seq > 0, got {stored_seq}");
284
285 let cursor = u64::try_from(stored_seq).expect("seq is non-negative");
287 let after = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
288 assert_eq!(after.len(), 0);
289 }
290
291 #[tokio::test]
300 async fn test_seq_cursor_survives_identical_created_at() {
301 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
302 .await
303 .unwrap();
304
305 let t = Utc::now();
306 let note1 = StoredNote {
307 header: test_note_header(),
308 details: vec![1],
309 created_at: t,
310 seq: 0,
311 after_block_num: None,
312 };
313 db.store_note(¬e1).await.unwrap();
314
315 let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
317 assert_eq!(first.len(), 1);
318 let cursor = u64::try_from(first[0].seq).expect("seq is non-negative");
319
320 let note2 = StoredNote {
322 header: test_note_header(),
323 details: vec![2],
324 created_at: t,
325 seq: 0,
326 after_block_num: None,
327 };
328 db.store_note(¬e2).await.unwrap();
329
330 let second = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
333 assert_eq!(
334 second.len(),
335 1,
336 "seq cursor must return note2 despite identical created_at; got {} notes",
337 second.len()
338 );
339 assert_eq!(second[0].details, vec![2]);
340 }
341
342 #[tokio::test]
354 async fn test_multi_tag_single_snapshot_vs_per_tag_loop() {
355 use std::convert::TryFrom;
356
357 const TAG_A: u32 = 0x3d9c_0000;
358 const TAG_B: u32 = 0x47ac_0000;
359
360 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
361 .await
362 .unwrap();
363
364 db.store_note(&StoredNote {
366 header: test_note_header_with_tag(TAG_A),
367 details: vec![1],
368 created_at: Utc::now(),
369 seq: 0,
370 after_block_num: None,
371 })
372 .await
373 .unwrap();
374
375 let a_result = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
378 assert_eq!(a_result.len(), 1);
379
380 db.store_note(&StoredNote {
385 header: test_note_header_with_tag(TAG_A),
386 details: vec![2],
387 created_at: Utc::now(),
388 seq: 0,
389 after_block_num: None,
390 })
391 .await
392 .unwrap();
393 db.store_note(&StoredNote {
394 header: test_note_header_with_tag(TAG_B),
395 details: vec![3],
396 created_at: Utc::now(),
397 seq: 0,
398 after_block_num: None,
399 })
400 .await
401 .unwrap();
402
403 let b_result = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
405 assert_eq!(b_result.len(), 1);
406
407 let rcursor: u64 = a_result
410 .iter()
411 .chain(b_result.iter())
412 .map(|n| u64::try_from(n.seq).unwrap())
413 .max()
414 .unwrap_or(0);
415
416 let retry_a = db.fetch_notes(TAG_A.into(), rcursor).await.unwrap();
418 let retry_b = db.fetch_notes(TAG_B.into(), rcursor).await.unwrap();
419
420 let per_tag_visible = a_result.len() + b_result.len() + retry_a.len() + retry_b.len();
423 assert_eq!(
424 per_tag_visible, 2,
425 "per-tag loop must lose the interleaved tag-A insert; visible = {per_tag_visible}"
426 );
427 assert!(
428 !retry_a.iter().any(|n| n.details == vec![2]),
429 "the interleaved tag-A insert with details=[2] must be unreachable to the per-tag loop"
430 );
431
432 let snapshot = db.fetch_notes_by_tags(&[TAG_A.into(), TAG_B.into()], 0).await.unwrap();
434 assert_eq!(
435 snapshot.len(),
436 3,
437 "fetch_notes_by_tags must return all 3 notes in a single consistent snapshot; got {}",
438 snapshot.len()
439 );
440 assert!(
441 snapshot.iter().any(|n| n.details == vec![2]),
442 "the interleaved tag-A insert MUST be visible via the single-snapshot query"
443 );
444 }
445
446 #[tokio::test]
451 async fn test_fetch_notes_resets_legacy_cursor() {
452 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
453 .await
454 .unwrap();
455
456 let note = StoredNote {
457 header: test_note_header(),
458 details: vec![1, 2, 3, 4],
459 created_at: Utc::now(),
460 seq: 0,
461 after_block_num: None,
462 };
463 db.store_note(¬e).await.unwrap();
464
465 let legacy_cursor: u64 = 1_760_000_000_000_000;
468 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), legacy_cursor).await.unwrap();
469 assert_eq!(
470 fetched.len(),
471 1,
472 "legacy microsecond cursor should be reset to 0, returning the note"
473 );
474
475 let normal_cursor: u64 = 1_000;
477 let empty = db.fetch_notes(TAG_LOCAL_ANY.into(), normal_cursor).await.unwrap();
478 assert_eq!(empty.len(), 0, "normal cursor > seq should filter correctly");
479 }
480
481 #[tokio::test]
485 async fn test_fetch_notes_paginates_at_batch_limit() {
486 use crate::database::sqlite::FETCH_NOTES_BATCH_SIZE;
487
488 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
489 .await
490 .unwrap();
491
492 let extra: usize = 7;
494 let total = usize::try_from(FETCH_NOTES_BATCH_SIZE).unwrap() + extra;
495 for i in 0..total {
496 db.store_note(&StoredNote {
497 header: test_note_header(),
498 details: vec![(i % 256) as u8],
499 created_at: Utc::now(),
500 seq: 0,
501 after_block_num: None,
502 })
503 .await
504 .unwrap();
505 }
506
507 let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
509 assert_eq!(
510 i64::try_from(first.len()).unwrap(),
511 FETCH_NOTES_BATCH_SIZE,
512 "first batch must be capped at FETCH_NOTES_BATCH_SIZE"
513 );
514
515 let advanced: u64 = first.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap();
517 let second = db.fetch_notes(TAG_LOCAL_ANY.into(), advanced).await.unwrap();
518 assert_eq!(second.len(), extra, "second batch must contain the remaining {extra} rows");
519
520 let third_cursor: u64 =
522 second.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap_or(advanced);
523 let third = db.fetch_notes(TAG_LOCAL_ANY.into(), third_cursor).await.unwrap();
524 assert_eq!(third.len(), 0, "drained");
525
526 let (total_stats, _) = db.get_stats().await.unwrap();
528 assert_eq!(usize::try_from(total_stats).unwrap(), total);
529 }
530
531 #[tokio::test]
538 async fn test_block_context_round_trips_through_store_and_fetch() {
539 use miden_note_transport_proto::miden_note_transport::TransportNote;
540
541 let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
542 .await
543 .unwrap();
544
545 let note = StoredNote {
547 header: test_note_header(),
548 details: vec![10, 20, 30],
549 created_at: Utc::now(),
550 seq: 0,
551 after_block_num: Some(12345),
552 };
553 db.store_note(¬e).await.unwrap();
554
555 let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
556 assert_eq!(fetched.len(), 1);
557 assert_eq!(fetched[0].after_block_num, Some(12345));
558
559 let proto: TransportNote = fetched.into_iter().next().unwrap().into();
561 assert_eq!(proto.after_block_num, Some(12345));
562
563 let note_max = StoredNote {
566 header: test_note_header(),
567 details: vec![99],
568 created_at: Utc::now(),
569 seq: 0,
570 after_block_num: Some(u32::MAX),
571 };
572 db.store_note(¬e_max).await.unwrap();
573
574 let all = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
576 let max_note = all.iter().find(|n| n.details == vec![99]).expect("u32::MAX note not found");
577 assert_eq!(
578 max_note.after_block_num,
579 Some(u32::MAX),
580 "u32::MAX must survive the u32 -> i64 -> u32 round-trip"
581 );
582 }
583}