1#[cfg(feature = "sqlite")]
9use std::collections::HashMap;
10#[cfg(feature = "sqlite")]
11use std::path::{Path, PathBuf};
12
13#[cfg(feature = "sqlite")]
14use chrono::{DateTime, Utc};
15#[cfg(feature = "sqlite")]
16use uuid::Uuid;
17
18#[cfg(feature = "sqlite")]
19use crate::crypto::FipsCrypto;
20#[cfg(feature = "sqlite")]
21use crate::error::{Result, VaultError};
22#[cfg(feature = "sqlite")]
23use crate::version::ModelVersion;
24
25#[cfg(feature = "sqlite")]
30pub struct SqliteVersionRepo {
31 conn: std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>,
32 vault_path: PathBuf,
33 cache: HashMap<String, Vec<ModelVersion>>,
35}
36
37#[cfg(feature = "sqlite")]
38impl SqliteVersionRepo {
39 pub fn new(vault_path: &Path) -> Result<Self> {
44 let db_path = vault_path.join("versions.db");
45 let conn = rusqlite::Connection::open(&db_path).map_err(|e| {
46 VaultError::StorageError(format!("Failed to open versions database: {}", e))
47 })?;
48
49 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
51 .map_err(|e| VaultError::StorageError(format!("Failed to set PRAGMA: {}", e)))?;
52
53 conn.execute_batch(
55 "CREATE TABLE IF NOT EXISTS versions (
56 id INTEGER PRIMARY KEY AUTOINCREMENT,
57 model_name TEXT NOT NULL,
58 version INTEGER NOT NULL,
59 checkpoint_id TEXT NOT NULL,
60 timestamp TEXT NOT NULL,
61 parent_version INTEGER,
62 format TEXT NOT NULL,
63 size_bytes INTEGER NOT NULL,
64 compressed_size_bytes INTEGER NOT NULL,
65 checksum_sha256 TEXT NOT NULL,
66 file_path TEXT NOT NULL,
67 UNIQUE(model_name, version)
68 );
69 CREATE TABLE IF NOT EXISTS version_metadata (
70 model_name TEXT NOT NULL,
71 version INTEGER NOT NULL,
72 key TEXT NOT NULL,
73 value TEXT NOT NULL,
74 PRIMARY KEY(model_name, version, key)
75 );
76 CREATE INDEX IF NOT EXISTS idx_versions_model ON versions(model_name);
77 CREATE INDEX IF NOT EXISTS idx_versions_model_ver ON versions(model_name, version);",
78 )
79 .map_err(|e| VaultError::StorageError(format!("Failed to create version tables: {}", e)))?;
80
81 let mut repo = Self {
82 conn: std::sync::Arc::new(std::sync::Mutex::new(conn)),
83 vault_path: vault_path.to_path_buf(),
84 cache: HashMap::new(),
85 };
86
87 repo.migrate_from_json()?;
89
90 repo.reload_cache()?;
92
93 Ok(repo)
94 }
95
96 pub fn in_memory() -> Result<Self> {
98 let conn = rusqlite::Connection::open_in_memory().map_err(|e| {
99 VaultError::StorageError(format!("Failed to create in-memory DB: {}", e))
100 })?;
101
102 conn.execute_batch(
103 "CREATE TABLE IF NOT EXISTS versions (
104 id INTEGER PRIMARY KEY AUTOINCREMENT,
105 model_name TEXT NOT NULL,
106 version INTEGER NOT NULL,
107 checkpoint_id TEXT NOT NULL,
108 timestamp TEXT NOT NULL,
109 parent_version INTEGER,
110 format TEXT NOT NULL,
111 size_bytes INTEGER NOT NULL,
112 compressed_size_bytes INTEGER NOT NULL,
113 checksum_sha256 TEXT NOT NULL,
114 file_path TEXT NOT NULL,
115 UNIQUE(model_name, version)
116 );
117 CREATE TABLE IF NOT EXISTS version_metadata (
118 model_name TEXT NOT NULL,
119 version INTEGER NOT NULL,
120 key TEXT NOT NULL,
121 value TEXT NOT NULL,
122 PRIMARY KEY(model_name, version, key)
123 );",
124 )
125 .map_err(|e| VaultError::StorageError(format!("Failed to create tables: {}", e)))?;
126
127 Ok(Self {
128 conn: std::sync::Arc::new(std::sync::Mutex::new(conn)),
129 vault_path: PathBuf::new(),
130 cache: HashMap::new(),
131 })
132 }
133
134 fn migrate_from_json(&self) -> Result<()> {
136 let json_path = self.vault_path.join("versions.json");
137 if !json_path.exists() {
138 return Ok(());
139 }
140
141 let contents = std::fs::read_to_string(&json_path)?;
142 let versions: HashMap<String, Vec<ModelVersion>> = serde_json::from_str(&contents)?;
143
144 let conn = self
145 .conn
146 .lock()
147 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
148
149 let tx = conn
150 .unchecked_transaction()
151 .map_err(|e| VaultError::StorageError(format!("Transaction failed: {}", e)))?;
152
153 for (model_name, model_versions) in &versions {
154 for mv in model_versions {
155 tx.execute(
156 "INSERT OR IGNORE INTO versions
157 (model_name, version, checkpoint_id, timestamp, parent_version,
158 format, size_bytes, compressed_size_bytes, checksum_sha256, file_path)
159 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
160 rusqlite::params![
161 model_name,
162 mv.version,
163 mv.checkpoint_id,
164 mv.timestamp.to_rfc3339(),
165 mv.parent_version,
166 mv.format,
167 mv.size_bytes,
168 mv.compressed_size_bytes,
169 mv.checksum_sha256,
170 mv.file_path,
171 ],
172 )
173 .map_err(|e| {
174 VaultError::StorageError(format!(
175 "Failed to migrate version {}/{}: {}",
176 model_name, mv.version, e
177 ))
178 })?;
179
180 for (key, value) in &mv.metadata {
182 tx.execute(
183 "INSERT OR IGNORE INTO version_metadata
184 (model_name, version, key, value)
185 VALUES (?1, ?2, ?3, ?4)",
186 rusqlite::params![model_name, mv.version, key, value],
187 )
188 .map_err(|e| {
189 VaultError::StorageError(format!("Failed to migrate metadata: {}", e))
190 })?;
191 }
192 }
193 }
194
195 tx.commit()
196 .map_err(|e| VaultError::StorageError(format!("Migration commit failed: {}", e)))?;
197
198 let migrated_path = self.vault_path.join("versions.json.migrated");
200 std::fs::rename(&json_path, &migrated_path)?;
201
202 Ok(())
203 }
204
205 fn reload_cache(&mut self) -> Result<()> {
207 let conn = self
208 .conn
209 .lock()
210 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
211
212 let mut cache: HashMap<String, Vec<ModelVersion>> = HashMap::new();
213
214 let mut stmt = conn
216 .prepare(
217 "SELECT model_name, version, checkpoint_id, timestamp, parent_version,
218 format, size_bytes, compressed_size_bytes, checksum_sha256, file_path
219 FROM versions ORDER BY model_name, version",
220 )
221 .map_err(|e| VaultError::StorageError(format!("Prepare failed: {}", e)))?;
222
223 let rows = stmt
224 .query_map([], |row| {
225 let model_name: String = row.get(0)?;
226 let timestamp_str: String = row.get(3)?;
227 let timestamp = DateTime::parse_from_rfc3339(×tamp_str)
228 .map(|dt| dt.with_timezone(&Utc))
229 .unwrap_or_else(|_| Utc::now());
230
231 Ok((
232 model_name,
233 ModelVersion {
234 version: row.get(1)?,
235 checkpoint_id: row.get(2)?,
236 timestamp,
237 parent_version: row.get(4)?,
238 format: row.get(5)?,
239 size_bytes: row.get(6)?,
240 compressed_size_bytes: row.get(7)?,
241 checksum_sha256: row.get(8)?,
242 file_path: row.get(9)?,
243 metadata: HashMap::new(), },
245 ))
246 })
247 .map_err(|e| VaultError::StorageError(format!("Query failed: {}", e)))?;
248
249 for row in rows {
250 let (model_name, mv) =
251 row.map_err(|e| VaultError::StorageError(format!("Row read failed: {}", e)))?;
252 cache.entry(model_name).or_default().push(mv);
253 }
254
255 let mut meta_stmt = conn
257 .prepare("SELECT model_name, version, key, value FROM version_metadata")
258 .map_err(|e| VaultError::StorageError(format!("Prepare failed: {}", e)))?;
259
260 let meta_rows = meta_stmt
261 .query_map([], |row| {
262 Ok((
263 row.get::<_, String>(0)?,
264 row.get::<_, u32>(1)?,
265 row.get::<_, String>(2)?,
266 row.get::<_, String>(3)?,
267 ))
268 })
269 .map_err(|e| VaultError::StorageError(format!("Meta query failed: {}", e)))?;
270
271 for row in meta_rows {
272 let (model_name, version, key, value) =
273 row.map_err(|e| VaultError::StorageError(format!("Meta row read failed: {}", e)))?;
274
275 if let Some(versions) = cache.get_mut(&model_name) {
276 if let Some(mv) = versions.iter_mut().find(|v| v.version == version) {
277 mv.metadata.insert(key, value);
278 }
279 }
280 }
281
282 drop(stmt);
283 drop(meta_stmt);
284 drop(conn);
285
286 self.cache = cache;
287 Ok(())
288 }
289
290 fn generate_checkpoint_id(model_name: &str, version: u32) -> String {
292 let uuid = Uuid::new_v4();
293 format!("{}-v{}-{}", model_name, version, uuid)
294 }
295
296 pub fn vault_path(&self) -> &Path {
298 &self.vault_path
299 }
300}
301
302#[cfg(feature = "sqlite")]
305impl crate::traits::VersionRepo for SqliteVersionRepo {
306 fn add_version(
307 &mut self,
308 model: &str,
309 file_path: &str,
310 format: &str,
311 size_bytes: u64,
312 compressed_size_bytes: u64,
313 checksum: &str,
314 metadata: Option<HashMap<String, String>>,
315 parent_version: Option<u32>,
316 ) -> Result<ModelVersion> {
317 let version = {
319 let conn = self
320 .conn
321 .lock()
322 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
323 let max_ver: Option<u32> = conn
324 .query_row(
325 "SELECT MAX(version) FROM versions WHERE model_name = ?1",
326 rusqlite::params![model],
327 |row| row.get(0),
328 )
329 .unwrap_or(None);
330 max_ver.unwrap_or(0) + 1
331 };
332
333 let timestamp = Utc::now();
334 let checkpoint_id = Self::generate_checkpoint_id(model, version);
335 let meta = metadata.unwrap_or_default();
336
337 {
338 let conn = self
339 .conn
340 .lock()
341 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
342
343 let tx = conn
344 .unchecked_transaction()
345 .map_err(|e| VaultError::StorageError(format!("Transaction failed: {}", e)))?;
346
347 tx.execute(
348 "INSERT INTO versions
349 (model_name, version, checkpoint_id, timestamp, parent_version,
350 format, size_bytes, compressed_size_bytes, checksum_sha256, file_path)
351 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
352 rusqlite::params![
353 model,
354 version,
355 checkpoint_id,
356 timestamp.to_rfc3339(),
357 parent_version,
358 format,
359 size_bytes,
360 compressed_size_bytes,
361 checksum,
362 file_path,
363 ],
364 )
365 .map_err(|e| VaultError::StorageError(format!("Insert version failed: {}", e)))?;
366
367 for (key, value) in &meta {
368 tx.execute(
369 "INSERT INTO version_metadata (model_name, version, key, value)
370 VALUES (?1, ?2, ?3, ?4)",
371 rusqlite::params![model, version, key, value],
372 )
373 .map_err(|e| VaultError::StorageError(format!("Insert metadata failed: {}", e)))?;
374 }
375
376 tx.commit()
377 .map_err(|e| VaultError::StorageError(format!("Commit failed: {}", e)))?;
378 }
379
380 let mv = ModelVersion {
381 version,
382 checkpoint_id,
383 timestamp,
384 parent_version,
385 format: format.to_string(),
386 size_bytes,
387 compressed_size_bytes,
388 checksum_sha256: checksum.to_string(),
389 metadata: meta,
390 file_path: file_path.to_string(),
391 };
392
393 self.cache
395 .entry(model.to_string())
396 .or_default()
397 .push(mv.clone());
398
399 Ok(mv)
400 }
401
402 fn get_version(&self, model: &str, version: Option<u32>) -> Option<&ModelVersion> {
403 let versions = self.cache.get(model)?;
404 if versions.is_empty() {
405 return None;
406 }
407 if let Some(v) = version {
408 versions.iter().find(|mv| mv.version == v)
409 } else {
410 versions.iter().max_by_key(|mv| mv.version)
411 }
412 }
413
414 fn list_versions(&self, model: &str) -> Vec<&ModelVersion> {
415 self.cache
416 .get(model)
417 .map(|v| {
418 let mut sorted: Vec<&ModelVersion> = v.iter().collect();
419 sorted.sort_by_key(|mv| mv.version);
420 sorted
421 })
422 .unwrap_or_default()
423 }
424
425 fn get_lineage(&self, model: &str, version: u32) -> Vec<&ModelVersion> {
426 let mut lineage = Vec::new();
427 if let Some(mut current) = self.get_version(model, Some(version)) {
428 lineage.push(current);
429 while let Some(parent_ver) = current.parent_version {
430 if let Some(parent) = self.get_version(model, Some(parent_ver)) {
431 lineage.insert(0, parent);
432 current = parent;
433 } else {
434 break;
435 }
436 }
437 }
438 lineage
439 }
440
441 fn delete_version(&mut self, model: &str, version: u32) -> Result<bool> {
442 let deleted = {
443 let conn = self
444 .conn
445 .lock()
446 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
447
448 let count = conn
449 .execute(
450 "DELETE FROM versions WHERE model_name = ?1 AND version = ?2",
451 rusqlite::params![model, version],
452 )
453 .map_err(|e| VaultError::StorageError(format!("Delete failed: {}", e)))?;
454
455 conn.execute(
456 "DELETE FROM version_metadata WHERE model_name = ?1 AND version = ?2",
457 rusqlite::params![model, version],
458 )
459 .map_err(|e| VaultError::StorageError(format!("Delete metadata failed: {}", e)))?;
460
461 count > 0
462 };
463
464 if let Some(versions) = self.cache.get_mut(model) {
466 versions.retain(|v| v.version != version);
467 }
468
469 Ok(deleted)
470 }
471
472 fn cleanup_old_versions(&mut self, model: &str, keep_count: usize) -> Result<Vec<u32>> {
473 let to_delete: Vec<u32> = {
474 let conn = self
475 .conn
476 .lock()
477 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
478
479 let mut stmt = conn
481 .prepare(
482 "SELECT version FROM versions WHERE model_name = ?1
483 ORDER BY version DESC",
484 )
485 .map_err(|e| VaultError::StorageError(format!("Prepare failed: {}", e)))?;
486
487 let all_versions: Vec<u32> = stmt
488 .query_map(rusqlite::params![model], |row| row.get(0))
489 .map_err(|e| VaultError::StorageError(format!("Query failed: {}", e)))?
490 .filter_map(|r| r.ok())
491 .collect();
492
493 if all_versions.len() <= keep_count {
494 return Ok(Vec::new());
495 }
496
497 all_versions[keep_count..].to_vec()
498 };
499
500 for version in &to_delete {
501 self.delete_version(model, *version)?;
502 }
503
504 Ok(to_delete)
505 }
506
507 fn verify_checksum(&self, model: &str, version: u32, data: &[u8]) -> bool {
508 if let Some(mv) = self.get_version(model, Some(version)) {
509 let checksum = hex::encode(FipsCrypto::hash_sha256(data));
510 return checksum == mv.checksum_sha256;
511 }
512 false
513 }
514
515 fn update_metadata(
516 &mut self,
517 model: &str,
518 version: u32,
519 key: &str,
520 value: String,
521 ) -> Result<()> {
522 {
523 let conn = self
524 .conn
525 .lock()
526 .map_err(|e| VaultError::StorageError(format!("Lock poisoned: {}", e)))?;
527
528 conn.execute(
529 "INSERT OR REPLACE INTO version_metadata (model_name, version, key, value)
530 VALUES (?1, ?2, ?3, ?4)",
531 rusqlite::params![model, version, key, value],
532 )
533 .map_err(|e| VaultError::StorageError(format!("Upsert metadata failed: {}", e)))?;
534 }
535
536 if let Some(versions) = self.cache.get_mut(model) {
538 if let Some(mv) = versions.iter_mut().find(|v| v.version == version) {
539 mv.metadata.insert(key.to_string(), value);
540 return Ok(());
541 }
542 }
543
544 Err(VaultError::VersionNotFound(version, model.to_string()))
545 }
546
547 fn get_metadata(&self, model: &str, version: u32, key: &str) -> Option<String> {
548 self.get_version(model, Some(version))
549 .and_then(|v| v.metadata.get(key).cloned())
550 }
551
552 fn list_models(&self) -> Vec<String> {
553 self.cache.keys().cloned().collect()
554 }
555}
556
557#[cfg(all(test, feature = "sqlite"))]
558mod tests {
559 use super::*;
560 use crate::traits::VersionRepo;
561
562 #[test]
563 fn test_sqlite_version_repo_basic() {
564 let mut repo = SqliteVersionRepo::in_memory().unwrap();
565
566 let v1 = repo
567 .add_version(
568 "test_model",
569 "test_file.enc",
570 "pytorch",
571 1000,
572 500,
573 "abc123",
574 None,
575 None,
576 )
577 .unwrap();
578
579 assert_eq!(v1.version, 1);
580 assert_eq!(v1.format, "pytorch");
581
582 let v2 = repo
583 .add_version(
584 "test_model",
585 "test_file2.enc",
586 "safetensors",
587 2000,
588 1000,
589 "def456",
590 None,
591 Some(1),
592 )
593 .unwrap();
594
595 assert_eq!(v2.version, 2);
596 assert_eq!(v2.parent_version, Some(1));
597
598 let latest = repo.get_version("test_model", None).unwrap();
600 assert_eq!(latest.version, 2);
601
602 let v1_ref = repo.get_version("test_model", Some(1)).unwrap();
603 assert_eq!(v1_ref.format, "pytorch");
604
605 let all = repo.list_versions("test_model");
607 assert_eq!(all.len(), 2);
608 assert_eq!(all[0].version, 1);
609 assert_eq!(all[1].version, 2);
610
611 let lineage = repo.get_lineage("test_model", 2);
613 assert_eq!(lineage.len(), 2);
614 }
615
616 #[test]
617 fn test_sqlite_version_repo_delete() {
618 let mut repo = SqliteVersionRepo::in_memory().unwrap();
619
620 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
621 .unwrap();
622 repo.add_version("m", "f2.enc", "pt", 200, 100, "c2", None, Some(1))
623 .unwrap();
624 repo.add_version("m", "f3.enc", "pt", 300, 150, "c3", None, Some(2))
625 .unwrap();
626
627 assert!(repo.delete_version("m", 2).unwrap());
628 assert_eq!(repo.list_versions("m").len(), 2);
629 assert!(!repo.delete_version("m", 99).unwrap());
630 }
631
632 #[test]
633 fn test_sqlite_version_repo_cleanup() {
634 let mut repo = SqliteVersionRepo::in_memory().unwrap();
635
636 for i in 0..5 {
637 repo.add_version(
638 "m",
639 &format!("f{}.enc", i),
640 "pt",
641 100,
642 50,
643 &format!("c{}", i),
644 None,
645 if i > 0 { Some(i) } else { None },
646 )
647 .unwrap();
648 }
649
650 let deleted = repo.cleanup_old_versions("m", 2).unwrap();
651 assert_eq!(deleted.len(), 3);
652 assert_eq!(repo.list_versions("m").len(), 2);
653 }
654
655 #[test]
656 fn test_sqlite_version_repo_metadata() {
657 let mut repo = SqliteVersionRepo::in_memory().unwrap();
658
659 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
660 .unwrap();
661
662 repo.update_metadata("m", 1, "author", "test_user".to_string())
663 .unwrap();
664
665 let val = repo.get_metadata("m", 1, "author");
666 assert_eq!(val, Some("test_user".to_string()));
667
668 repo.update_metadata("m", 1, "author", "new_user".to_string())
670 .unwrap();
671 assert_eq!(
672 repo.get_metadata("m", 1, "author"),
673 Some("new_user".to_string())
674 );
675 }
676
677 #[test]
678 fn test_sqlite_version_repo_list_models() {
679 let mut repo = SqliteVersionRepo::in_memory().unwrap();
680
681 repo.add_version("alpha", "f1.enc", "pt", 100, 50, "c1", None, None)
682 .unwrap();
683 repo.add_version("beta", "f2.enc", "onnx", 200, 100, "c2", None, None)
684 .unwrap();
685
686 let mut models = repo.list_models();
687 models.sort();
688 assert_eq!(models, vec!["alpha", "beta"]);
689 }
690
691 #[test]
692 fn test_sqlite_migration_from_json() {
693 let temp_dir = tempfile::tempdir().unwrap();
694 let vault_path = temp_dir.path();
695
696 let mut versions: HashMap<String, Vec<ModelVersion>> = HashMap::new();
698 versions.insert(
699 "legacy_model".to_string(),
700 vec![ModelVersion {
701 version: 1,
702 checkpoint_id: "legacy-v1-test".to_string(),
703 timestamp: Utc::now(),
704 parent_version: None,
705 format: "pytorch".to_string(),
706 size_bytes: 999,
707 compressed_size_bytes: 500,
708 checksum_sha256: "legacy_checksum".to_string(),
709 metadata: {
710 let mut m = HashMap::new();
711 m.insert("key1".to_string(), "value1".to_string());
712 m
713 },
714 file_path: "legacy.enc".to_string(),
715 }],
716 );
717
718 let json_content = serde_json::to_string_pretty(&versions).unwrap();
719 std::fs::write(vault_path.join("versions.json"), json_content).unwrap();
720
721 let repo = SqliteVersionRepo::new(vault_path).unwrap();
723
724 let models = repo.list_models();
726 assert_eq!(models, vec!["legacy_model"]);
727
728 let v1 = repo.get_version("legacy_model", Some(1)).unwrap();
729 assert_eq!(v1.format, "pytorch");
730 assert_eq!(v1.size_bytes, 999);
731 assert_eq!(v1.metadata.get("key1"), Some(&"value1".to_string()));
732
733 assert!(!vault_path.join("versions.json").exists());
735 assert!(vault_path.join("versions.json.migrated").exists());
736 }
737
738 #[test]
739 fn test_sqlite_new_on_disk_and_reopen() {
740 let temp_dir = tempfile::tempdir().unwrap();
742 let vault_path = temp_dir.path();
743
744 {
746 let mut repo = SqliteVersionRepo::new(vault_path).unwrap();
747 repo.add_version("disk_model", "f.enc", "onnx", 500, 250, "chk1", None, None)
748 .unwrap();
749 repo.update_metadata("disk_model", 1, "source", "test".to_string())
750 .unwrap();
751 }
752
753 let repo2 = SqliteVersionRepo::new(vault_path).unwrap();
755 let v = repo2.get_version("disk_model", Some(1)).unwrap();
756 assert_eq!(v.format, "onnx");
757 assert_eq!(v.metadata.get("source"), Some(&"test".to_string()));
758 }
759
760 #[test]
761 fn test_sqlite_verify_checksum() {
762 let mut repo = SqliteVersionRepo::in_memory().unwrap();
764 let data = b"model data";
765 let checksum = hex::encode(FipsCrypto::hash_sha256(data));
766 repo.add_version("m", "f.enc", "pt", 100, 50, &checksum, None, None)
767 .unwrap();
768
769 assert!(repo.verify_checksum("m", 1, data));
770 assert!(!repo.verify_checksum("m", 1, b"tampered"));
771 assert!(!repo.verify_checksum("m", 99, data)); }
773
774 #[test]
775 fn test_sqlite_get_version_none() {
776 let repo = SqliteVersionRepo::in_memory().unwrap();
777 assert!(repo.get_version("nonexistent", None).is_none());
778 assert!(repo.get_version("nonexistent", Some(1)).is_none());
779 }
780
781 #[test]
782 fn test_sqlite_add_version_with_metadata() {
783 let mut repo = SqliteVersionRepo::in_memory().unwrap();
784 let mut meta = HashMap::new();
785 meta.insert("author".to_string(), "tester".to_string());
786 meta.insert("framework".to_string(), "pytorch".to_string());
787 let v = repo
788 .add_version("m", "f.enc", "pt", 100, 50, "c1", Some(meta), None)
789 .unwrap();
790 assert_eq!(v.version, 1);
791 assert_eq!(v.metadata.get("author"), Some(&"tester".to_string()));
792 }
793
794 #[test]
795 fn test_sqlite_update_metadata_nonexistent_version() {
796 let mut repo = SqliteVersionRepo::in_memory().unwrap();
797 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
798 .unwrap();
799 let result = repo.update_metadata("m", 99, "key", "val".to_string());
800 assert!(result.is_err());
801 }
802
803 #[test]
804 fn test_sqlite_get_metadata_none() {
805 let mut repo = SqliteVersionRepo::in_memory().unwrap();
806 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
807 .unwrap();
808 assert!(repo.get_metadata("m", 1, "nonexistent").is_none());
809 assert!(repo.get_metadata("m", 99, "any").is_none());
810 }
811
812 #[test]
813 fn test_sqlite_lineage_chain() {
814 let mut repo = SqliteVersionRepo::in_memory().unwrap();
815 repo.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
816 .unwrap();
817 repo.add_version("m", "f2.enc", "pt", 100, 50, "c2", None, Some(1))
818 .unwrap();
819 repo.add_version("m", "f3.enc", "pt", 100, 50, "c3", None, Some(2))
820 .unwrap();
821
822 let lineage = repo.get_lineage("m", 3);
823 assert_eq!(lineage.len(), 3);
824 assert_eq!(lineage[0].version, 1);
825 assert_eq!(lineage[2].version, 3);
826 }
827
828 #[test]
829 fn test_sqlite_cleanup_noop() {
830 let mut repo = SqliteVersionRepo::in_memory().unwrap();
831 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
832 .unwrap();
833 let deleted = repo.cleanup_old_versions("m", 10).unwrap();
834 assert!(deleted.is_empty());
835 }
836
837 #[test]
838 fn test_sqlite_vault_path() {
839 let dir = tempfile::tempdir().unwrap();
840 let repo = SqliteVersionRepo::new(dir.path()).unwrap();
841 assert_eq!(repo.vault_path(), dir.path());
842 }
843
844 #[test]
845 fn test_sqlite_delete_nonexistent_model() {
846 let mut repo = SqliteVersionRepo::in_memory().unwrap();
847 let result = repo.delete_version("no_model", 1).unwrap();
849 assert!(!result);
850 }
851
852 #[test]
853 fn test_sqlite_cleanup_empty_model() {
854 let mut repo = SqliteVersionRepo::in_memory().unwrap();
855 let deleted = repo.cleanup_old_versions("no_model", 5).unwrap();
857 assert!(deleted.is_empty());
858 }
859
860 #[test]
861 fn test_sqlite_list_versions_empty() {
862 let repo = SqliteVersionRepo::in_memory().unwrap();
863 let versions = repo.list_versions("nonexistent");
864 assert!(versions.is_empty());
865 }
866
867 #[test]
868 fn test_sqlite_list_models_empty() {
869 let repo = SqliteVersionRepo::in_memory().unwrap();
870 assert!(repo.list_models().is_empty());
871 }
872
873 #[test]
874 fn test_sqlite_lineage_nonexistent() {
875 let repo = SqliteVersionRepo::in_memory().unwrap();
876 let lineage = repo.get_lineage("no_model", 1);
877 assert!(lineage.is_empty());
878 }
879
880 #[test]
881 fn test_sqlite_lineage_no_parent() {
882 let mut repo = SqliteVersionRepo::in_memory().unwrap();
883 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
884 .unwrap();
885 let lineage = repo.get_lineage("m", 1);
886 assert_eq!(lineage.len(), 1);
887 assert_eq!(lineage[0].version, 1);
888 }
889
890 #[test]
891 fn test_sqlite_lineage_broken_chain() {
892 let mut repo = SqliteVersionRepo::in_memory().unwrap();
894 repo.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
895 .unwrap();
896 repo.add_version("m", "f2.enc", "pt", 100, 50, "c2", None, Some(1))
897 .unwrap();
898 repo.add_version("m", "f3.enc", "pt", 100, 50, "c3", None, Some(2))
899 .unwrap();
900 repo.delete_version("m", 1).unwrap();
902 let lineage = repo.get_lineage("m", 3);
904 assert_eq!(lineage.len(), 2);
905 assert_eq!(lineage[0].version, 2);
906 assert_eq!(lineage[1].version, 3);
907 }
908
909 #[test]
910 fn test_sqlite_get_version_specific_and_latest() {
911 let mut repo = SqliteVersionRepo::in_memory().unwrap();
912 repo.add_version("m", "f1.enc", "pt", 100, 50, "c1", None, None)
913 .unwrap();
914 repo.add_version("m", "f2.enc", "onnx", 200, 100, "c2", None, Some(1))
915 .unwrap();
916 repo.add_version("m", "f3.enc", "st", 300, 150, "c3", None, Some(2))
917 .unwrap();
918
919 assert_eq!(repo.get_version("m", Some(1)).unwrap().format, "pt");
921 assert_eq!(repo.get_version("m", Some(2)).unwrap().format, "onnx");
922
923 assert_eq!(repo.get_version("m", None).unwrap().version, 3);
925
926 assert!(repo.get_version("m", Some(99)).is_none());
928 }
929
930 #[test]
931 fn test_sqlite_verify_checksum_nonexistent_model() {
932 let repo = SqliteVersionRepo::in_memory().unwrap();
933 assert!(!repo.verify_checksum("no_model", 1, b"data"));
934 }
935
936 #[test]
937 fn test_sqlite_get_metadata_nonexistent_model() {
938 let repo = SqliteVersionRepo::in_memory().unwrap();
939 assert!(repo.get_metadata("no_model", 1, "key").is_none());
940 }
941
942 #[test]
943 fn test_sqlite_multiple_models_independent() {
944 let mut repo = SqliteVersionRepo::in_memory().unwrap();
945 repo.add_version("alpha", "a.enc", "pt", 100, 50, "ca", None, None)
946 .unwrap();
947 repo.add_version("beta", "b.enc", "onnx", 200, 100, "cb", None, None)
948 .unwrap();
949 repo.add_version("alpha", "a2.enc", "st", 300, 150, "ca2", None, Some(1))
950 .unwrap();
951
952 assert_eq!(repo.list_versions("alpha").len(), 2);
954 assert_eq!(repo.list_versions("beta").len(), 1);
955
956 repo.delete_version("alpha", 1).unwrap();
958 assert_eq!(repo.list_versions("alpha").len(), 1);
959 assert_eq!(repo.list_versions("beta").len(), 1);
960
961 let mut models = repo.list_models();
962 models.sort();
963 assert_eq!(models, vec!["alpha", "beta"]);
964 }
965
966 #[test]
967 fn test_sqlite_add_version_auto_increment() {
968 let mut repo = SqliteVersionRepo::in_memory().unwrap();
969 let v1 = repo
970 .add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
971 .unwrap();
972 let v2 = repo
973 .add_version("m", "f2.enc", "pt", 100, 50, "c2", None, None)
974 .unwrap();
975 let v3 = repo
976 .add_version("m", "f3.enc", "pt", 100, 50, "c3", None, None)
977 .unwrap();
978 assert_eq!(v1.version, 1);
979 assert_eq!(v2.version, 2);
980 assert_eq!(v3.version, 3);
981 }
982
983 #[test]
984 fn test_sqlite_update_metadata_multiple_keys() {
985 let mut repo = SqliteVersionRepo::in_memory().unwrap();
986 repo.add_version("m", "f.enc", "pt", 100, 50, "c1", None, None)
987 .unwrap();
988
989 repo.update_metadata("m", 1, "key1", "val1".to_string())
990 .unwrap();
991 repo.update_metadata("m", 1, "key2", "val2".to_string())
992 .unwrap();
993 repo.update_metadata("m", 1, "key3", "val3".to_string())
994 .unwrap();
995
996 assert_eq!(repo.get_metadata("m", 1, "key1"), Some("val1".to_string()));
997 assert_eq!(repo.get_metadata("m", 1, "key2"), Some("val2".to_string()));
998 assert_eq!(repo.get_metadata("m", 1, "key3"), Some("val3".to_string()));
999 }
1000
1001 #[test]
1002 fn test_sqlite_cleanup_keeps_latest() {
1003 let mut repo = SqliteVersionRepo::in_memory().unwrap();
1004 for i in 0..10 {
1005 repo.add_version(
1006 "m",
1007 &format!("f{}.enc", i),
1008 "pt",
1009 100,
1010 50,
1011 &format!("c{}", i),
1012 None,
1013 if i > 0 { Some(i as u32) } else { None },
1014 )
1015 .unwrap();
1016 }
1017
1018 let deleted = repo.cleanup_old_versions("m", 3).unwrap();
1019 assert_eq!(deleted.len(), 7);
1020 let remaining = repo.list_versions("m");
1021 assert_eq!(remaining.len(), 3);
1022 let versions: Vec<u32> = remaining.iter().map(|v| v.version).collect();
1024 assert!(versions.contains(&8));
1025 assert!(versions.contains(&9));
1026 assert!(versions.contains(&10));
1027 }
1028
1029 #[test]
1030 fn test_sqlite_migration_no_json_file() {
1031 let dir = tempfile::tempdir().unwrap();
1033 let repo = SqliteVersionRepo::new(dir.path()).unwrap();
1034 assert!(repo.list_models().is_empty());
1035 }
1036
1037 #[test]
1038 fn test_sqlite_migration_with_metadata() {
1039 let dir = tempfile::tempdir().unwrap();
1040 let vault_path = dir.path();
1041
1042 let mut versions: HashMap<String, Vec<ModelVersion>> = HashMap::new();
1043 let mut meta = HashMap::new();
1044 meta.insert("author".to_string(), "migrator".to_string());
1045 meta.insert("task".to_string(), "classification".to_string());
1046
1047 versions.insert(
1048 "migrated_model".to_string(),
1049 vec![ModelVersion {
1050 version: 1,
1051 checkpoint_id: "mig-v1".to_string(),
1052 timestamp: Utc::now(),
1053 parent_version: None,
1054 format: "safetensors".to_string(),
1055 size_bytes: 5000,
1056 compressed_size_bytes: 2500,
1057 checksum_sha256: "mig_chk".to_string(),
1058 metadata: meta,
1059 file_path: "migrated.enc".to_string(),
1060 }],
1061 );
1062
1063 std::fs::write(
1064 vault_path.join("versions.json"),
1065 serde_json::to_string(&versions).unwrap(),
1066 )
1067 .unwrap();
1068
1069 let repo = SqliteVersionRepo::new(vault_path).unwrap();
1070 let v = repo.get_version("migrated_model", Some(1)).unwrap();
1071 assert_eq!(v.format, "safetensors");
1072 assert_eq!(v.metadata.get("author"), Some(&"migrator".to_string()));
1073 assert_eq!(v.metadata.get("task"), Some(&"classification".to_string()));
1074 }
1075
1076 #[test]
1077 fn test_sqlite_disk_persistence_metadata() {
1078 let dir = tempfile::tempdir().unwrap();
1080 {
1081 let mut repo = SqliteVersionRepo::new(dir.path()).unwrap();
1082 let mut meta = HashMap::new();
1083 meta.insert("framework".to_string(), "pytorch".to_string());
1084 repo.add_version("persist", "f.enc", "pt", 100, 50, "c1", Some(meta), None)
1085 .unwrap();
1086 repo.update_metadata("persist", 1, "extra", "value".to_string())
1087 .unwrap();
1088 }
1089 let repo2 = SqliteVersionRepo::new(dir.path()).unwrap();
1091 let v = repo2.get_version("persist", Some(1)).unwrap();
1092 assert_eq!(v.metadata.get("framework"), Some(&"pytorch".to_string()));
1093 assert_eq!(v.metadata.get("extra"), Some(&"value".to_string()));
1094 }
1095
1096 #[test]
1097 fn test_sqlite_generate_checkpoint_id() {
1098 let id1 = SqliteVersionRepo::generate_checkpoint_id("model", 1);
1099 let id2 = SqliteVersionRepo::generate_checkpoint_id("model", 1);
1100 assert!(id1.starts_with("model-v1-"));
1101 assert!(id2.starts_with("model-v1-"));
1102 assert_ne!(id1, id2);
1104 }
1105}