package infra import ( "fmt" "os" "path/filepath" "time" ) // AggregateReport summarises the result of a VaultAggregateIndex run. type AggregateReport struct { VaultsProcessed int VaultsSkipped int // vaults without a vault_index.db TotalFiles int Errors []string // non-fatal per-vault errors } // VaultAggregateIndex reads all vault manifests from repoRoot, opens each // vault_index.db and copies all file records into the central registry.db // vault_files table. The table is created if it does not exist (idempotent). // // For each vault the previous rows are deleted and replaced atomically, so // re-running always produces a clean, non-duplicated state. // // Returns an AggregateReport with counts. Per-vault errors are non-fatal // (logged in report.Errors); only fatal errors (e.g. registry.db // unreachable) are returned as the error value. func VaultAggregateIndex(repoRoot string) (AggregateReport, error) { var report AggregateReport // 1. Open registry.db registryDB, err := SQLiteOpen(filepath.Join(repoRoot, "registry.db"), "") if err != nil { return report, fmt.Errorf("vault_aggregate_index: open registry.db: %w", err) } defer registryDB.Close() // 2. Idempotent schema migration for _, stmt := range []string{ `CREATE TABLE IF NOT EXISTS vault_files ( vault_id TEXT NOT NULL, vault_name TEXT NOT NULL, rel_path TEXT NOT NULL, size INTEGER NOT NULL, mtime INTEGER NOT NULL, sha256 TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '', ext TEXT NOT NULL DEFAULT '', bucket TEXT NOT NULL DEFAULT '', sub_bucket TEXT NOT NULL DEFAULT '', indexed_at INTEGER NOT NULL, PRIMARY KEY (vault_id, rel_path) );`, `CREATE INDEX IF NOT EXISTS idx_vault_files_sha256 ON vault_files(sha256);`, `CREATE INDEX IF NOT EXISTS idx_vault_files_vault ON vault_files(vault_id);`, } { if _, err := registryDB.Exec(stmt); err != nil { if !isIdempotentMigrationError(err) { return report, fmt.Errorf("vault_aggregate_index: schema: %w", err) } } } // 3. Read manifest entries, err := VaultManifestRead(repoRoot) if err != nil { return report, fmt.Errorf("vault_aggregate_index: manifest: %w", err) } now := time.Now().UTC().Unix() for _, entry := range entries { vaultID := vaultIDFromEntry(entry) vaultName := entry.Name vaultPath := entry.Path indexPath := filepath.Join(vaultPath, "vault_index.db") if _, statErr := os.Stat(indexPath); statErr != nil { report.VaultsSkipped++ continue } vaultDB, openErr := VaultIndexOpen(vaultPath) if openErr != nil { report.Errors = append(report.Errors, fmt.Sprintf("%s: open index: %v", vaultName, openErr)) continue } rows, queryErr := vaultDB.Query( `SELECT rel_path, size, mtime, sha256, mime, ext, bucket, sub_bucket FROM files`, ) if queryErr != nil { vaultDB.Close() report.Errors = append(report.Errors, fmt.Sprintf("%s: query files: %v", vaultName, queryErr)) continue } type fileRow struct { RelPath string Size int64 Mtime int64 Sha256 string Mime string Ext string Bucket string SubBucket string } var fileRows []fileRow for rows.Next() { var r fileRow if scanErr := rows.Scan(&r.RelPath, &r.Size, &r.Mtime, &r.Sha256, &r.Mime, &r.Ext, &r.Bucket, &r.SubBucket); scanErr != nil { continue } fileRows = append(fileRows, r) } rows.Close() vaultDB.Close() // Atomic replace in registry.db tx, txErr := registryDB.Begin() if txErr != nil { report.Errors = append(report.Errors, fmt.Sprintf("%s: begin tx: %v", vaultName, txErr)) continue } if _, delErr := tx.Exec(`DELETE FROM vault_files WHERE vault_id = ?`, vaultID); delErr != nil { tx.Rollback() report.Errors = append(report.Errors, fmt.Sprintf("%s: delete: %v", vaultName, delErr)) continue } stmt, prepErr := tx.Prepare(` INSERT INTO vault_files (vault_id, vault_name, rel_path, size, mtime, sha256, mime, ext, bucket, sub_bucket, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if prepErr != nil { tx.Rollback() report.Errors = append(report.Errors, fmt.Sprintf("%s: prepare: %v", vaultName, prepErr)) continue } for _, r := range fileRows { if _, insErr := stmt.Exec(vaultID, vaultName, r.RelPath, r.Size, r.Mtime, r.Sha256, r.Mime, r.Ext, r.Bucket, r.SubBucket, now); insErr != nil { stmt.Close() tx.Rollback() report.Errors = append(report.Errors, fmt.Sprintf("%s: insert %s: %v", vaultName, r.RelPath, insErr)) continue } } stmt.Close() if commitErr := tx.Commit(); commitErr != nil { report.Errors = append(report.Errors, fmt.Sprintf("%s: commit: %v", vaultName, commitErr)) continue } report.VaultsProcessed++ report.TotalFiles += len(fileRows) } return report, nil } // vaultIDFromEntry constructs the canonical vault ID used in registry.db. // Pattern: "_" — consistent with the vaults table. func vaultIDFromEntry(e VaultManifestEntry) string { if e.ProjectID == "" { return e.Name } return e.Name + "_" + e.ProjectID }