Skip to main content

ai_model_vault/
version_sqlite.rs

1//! SQLite-backed version repository.
2//!
3//! Provides ACID, indexed, concurrent-safe version storage via SQLite.
4//! Auto-migrates from the legacy `versions.json` format on first access.
5//!
6//! Feature-gated behind `sqlite`.
7
8#[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/// SQLite-backed version repository with ACID guarantees.
26///
27/// Maintains an in-memory cache synchronized with the database
28/// so that the `VersionRepo` trait (which returns references) works.
29#[cfg(feature = "sqlite")]
30pub struct SqliteVersionRepo {
31    conn: std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>,
32    vault_path: PathBuf,
33    /// In-memory cache for reference-returning trait methods.
34    cache: HashMap<String, Vec<ModelVersion>>,
35}
36
37#[cfg(feature = "sqlite")]
38impl SqliteVersionRepo {
39    /// Open (or create) a SQLite version database in the vault directory.
40    ///
41    /// If a legacy `versions.json` file exists, it is auto-migrated
42    /// into the database, then renamed to `versions.json.migrated`.
43    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        // Enable WAL mode for better concurrent read performance
50        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        // Create tables
54        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        // Auto-migrate from versions.json if it exists
88        repo.migrate_from_json()?;
89
90        // Load cache from database
91        repo.reload_cache()?;
92
93        Ok(repo)
94    }
95
96    /// Create an in-memory SQLite version repository (for testing).
97    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    /// Migrate legacy `versions.json` into the database.
135    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                // Migrate metadata
181                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        // Rename the old file to mark migration as complete
199        let migrated_path = self.vault_path.join("versions.json.migrated");
200        std::fs::rename(&json_path, &migrated_path)?;
201
202        Ok(())
203    }
204
205    /// Reload the in-memory cache from the database.
206    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        // Load all versions
215        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(&timestamp_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(), // loaded below
244                    },
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        // Load all metadata
256        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    /// Generate unique checkpoint identifier.
291    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    /// Return the vault directory path.
297    pub fn vault_path(&self) -> &Path {
298        &self.vault_path
299    }
300}
301
302// ── VersionRepo trait implementation ─────────────────────────
303
304#[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        // Determine next version number
318        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        // Update cache
394        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        // Update cache
465        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            // Get versions to delete (all except the N most recent)
480            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        // Update cache
537        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        // Test get_version
599        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        // Test list_versions
606        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        // Test lineage
612        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        // Update existing key
669        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        // Create a legacy versions.json
697        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        // Open SQLite repo — should auto-migrate
722        let repo = SqliteVersionRepo::new(vault_path).unwrap();
723
724        // Verify migration
725        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        // Verify the old file was renamed
734        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        // Covers lines 46-103 — new() on disk with WAL, table creation, reload_cache
741        let temp_dir = tempfile::tempdir().unwrap();
742        let vault_path = temp_dir.path();
743
744        // Create on disk + add a version
745        {
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        // Reopen — exercises reload_cache (lines 129-300+)
754        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        // Covers lines 497, 501, 509
763        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)); // nonexistent version
772    }
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        // Delete from a model that doesn't exist — should return Ok(false)
848        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        // Cleanup on a model with no versions — should return empty vec
856        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        // Lineage with a missing parent version
893        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        // Delete version 1, breaking the chain
901        repo.delete_version("m", 1).unwrap();
902        // Lineage from v3 should stop at v2 (parent v1 missing)
903        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        // Get specific versions
920        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        // Get latest (None) should return v3
924        assert_eq!(repo.get_version("m", None).unwrap().version, 3);
925
926        // Get nonexistent version number
927        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        // Alpha has 2 versions, beta has 1
953        assert_eq!(repo.list_versions("alpha").len(), 2);
954        assert_eq!(repo.list_versions("beta").len(), 1);
955
956        // Delete alpha v1 — beta should be unaffected
957        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        // Kept should be the 3 most recent (v8, v9, v10)
1023        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        // When no versions.json exists, migration should be a no-op
1032        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        // Verify metadata survives close + reopen
1079        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        // Reopen
1090        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        // UUIDs should differ
1103        assert_ne!(id1, id2);
1104    }
1105}