diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index b1362be6d4..795105eb09 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -124,6 +124,7 @@ export async function reinstallJre(jreKey?: string, operationId?: string) { export const uninstallJre = forward("uninstallJre"); export const listenAgentInstallProgress = forward("listenAgentInstallProgress"); export const loadSavedSqlLibrary = forward("loadSavedSqlLibrary"); +export const loadSavedSqlFilesForSync = forward("loadSavedSqlFilesForSync"); export const loadSavedSqlFile = forward("loadSavedSqlFile"); export const saveSavedSqlFolder = forward("saveSavedSqlFolder"); export const deleteSavedSqlFolder = forward("deleteSavedSqlFolder"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 397244403c..797d8195d9 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -651,6 +651,10 @@ export async function loadSavedSqlLibrary(): Promise { return get("/api/saved-sql"); } +export async function loadSavedSqlFilesForSync(): Promise { + throw new Error("SQL directory sync is only available in the desktop app."); +} + export async function loadSavedSqlFile(id: string): Promise { return get(`/api/saved-sql/${encodeURIComponent(id)}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 692e54fcb9..26feb539b4 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1942,6 +1942,10 @@ export async function loadSavedSqlLibrary(): Promise { return invoke("load_saved_sql_library"); } +export async function loadSavedSqlFilesForSync(): Promise { + return invoke("load_saved_sql_files_for_sync"); +} + export async function loadSavedSqlFile(id: string): Promise { return invoke("load_saved_sql_file", { id }); } diff --git a/apps/desktop/src/stores/__tests__/savedSqlStore.sync.spec.ts b/apps/desktop/src/stores/__tests__/savedSqlStore.sync.spec.ts new file mode 100644 index 0000000000..885e88385a --- /dev/null +++ b/apps/desktop/src/stores/__tests__/savedSqlStore.sync.spec.ts @@ -0,0 +1,123 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "@/lib/backend/api"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; + +const mocks = vi.hoisted(() => ({ + syncDir: { value: "/workspace/dbx-sql-sync" as string | null }, +})); + +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + desktopSettings: { saved_sql_sync_dir: mocks.syncDir.value }, + }), +})); +vi.mock("@/lib/backend/api", () => ({ + loadSavedSqlLibrary: vi.fn(), + loadSavedSqlFilesForSync: vi.fn(), + loadSavedSqlFile: vi.fn(), + saveSavedSqlFile: vi.fn(), + syncSavedSqlDirectory: vi.fn(), +})); + +function library(sqlLoaded: boolean) { + return { + folders: [ + { + id: "folder-1", + connectionId: "connection-1", + name: "Reports", + orderIndex: 0, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }, + ], + files: [ + { + id: "file-1", + connectionId: "connection-1", + folderId: "folder-1", + name: "daily.sql", + database: "", + sql: sqlLoaded ? "SELECT 1;" : "", + sqlLoaded, + orderIndex: 0, + openCount: 0, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }, + { + id: "file-2", + connectionId: "connection-1", + folderId: "folder-1", + name: "weekly.sql", + database: "", + sql: sqlLoaded ? "SELECT 7;" : "", + sqlLoaded, + orderIndex: 1, + openCount: 0, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }, + ], + }; +} + +describe("savedSqlStore directory sync", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + }); + setActivePinia(createPinia()); + vi.clearAllMocks(); + mocks.syncDir.value = "/workspace/dbx-sql-sync"; + vi.mocked(api.loadSavedSqlLibrary).mockResolvedValue(library(false)); + vi.mocked(api.loadSavedSqlFilesForSync).mockResolvedValue(library(true).files); + vi.mocked(api.saveSavedSqlFile).mockImplementation(async (file) => file); + vi.mocked(api.syncSavedSqlDirectory).mockResolvedValue(undefined); + }); + + it("hydrates an unloaded library with one batch request and reuses it", async () => { + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await store.syncToLocalDirectory(); + await store.syncToLocalDirectory(); + + expect(api.loadSavedSqlFilesForSync).toHaveBeenCalledTimes(1); + expect(api.loadSavedSqlFile).not.toHaveBeenCalled(); + expect(api.syncSavedSqlDirectory).toHaveBeenCalledTimes(2); + expect(api.syncSavedSqlDirectory).toHaveBeenLastCalledWith({ + targetDir: "/workspace/dbx-sql-sync", + entries: [ + { folderName: "Reports", fileName: "daily.sql", sql: "SELECT 1;" }, + { folderName: "Reports", fileName: "weekly.sql", sql: "SELECT 7;" }, + ], + }); + }); + + it("does not add a sync read or request when directory sync is disabled", async () => { + mocks.syncDir.value = null; + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await store.saveFile({ + id: "file-1", + connectionId: "connection-1", + folderId: "folder-1", + name: "daily.sql", + database: "", + sql: "SELECT 2;", + }); + + expect(api.saveSavedSqlFile).toHaveBeenCalledTimes(1); + expect(api.loadSavedSqlFilesForSync).not.toHaveBeenCalled(); + expect(api.loadSavedSqlFile).not.toHaveBeenCalled(); + expect(api.syncSavedSqlDirectory).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index a1cea7b815..ec44989d17 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -577,6 +577,17 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } async function syncEntries() { + if (files.value.some((file) => file.sqlLoaded === false)) { + const loadedFiles = await api.loadSavedSqlFilesForSync(); + const loadedById = new Map(loadedFiles.map((file) => [file.id, file])); + files.value = files.value.map((file) => { + if (file.sqlLoaded !== false) return file; + const loaded = loadedById.get(file.id); + return loaded ? { ...file, sql: loaded.sql, sqlLoaded: true } : file; + }); + bumpVersion(); + } + const folderById = new Map(folders.value.map((folder) => [folder.id, folder])); const folderPath = (folderId?: string): string | undefined => { if (!folderId) return undefined; @@ -590,14 +601,14 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } return parts.join("/"); }; - const loadedFiles = await Promise.all(sortFilesByOrder(files.value).map((file) => ensureFileContent(file.id))); - return loadedFiles - .filter((file): file is SavedSqlFile => Boolean(file)) - .map((file) => ({ - folderName: folderPath(file.folderId), - fileName: file.name, - sql: file.sql, - })); + const unloadedFile = files.value.find((file) => file.sqlLoaded === false); + if (unloadedFile) throw new Error(`Saved SQL file '${unloadedFile.id}' could not be loaded for directory sync.`); + + return sortFilesByOrder(files.value).map((file) => ({ + folderName: folderPath(file.folderId), + fileName: file.name, + sql: file.sql, + })); } async function syncToLocalDirectory() { diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 50476fe499..3ef5dda31a 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -2960,6 +2960,41 @@ impl Storage { .await } + pub async fn load_saved_sql_files_for_sync(&self) -> Result, String> { + self.with_conn(|conn| { + let mut stmt = conn + .prepare( + "SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ + FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", + ) + .map_err(|e| e.to_string())?; + let files = stmt + .query_map([], |row| { + Ok(SavedSqlFile { + id: row.get(0)?, + connection_id: row.get(1)?, + folder_id: row.get(2)?, + name: row.get(3)?, + database: row.get(4)?, + catalog: row.get(5)?, + schema: row.get(6)?, + sql: row.get(7)?, + sql_loaded: true, + order_index: row.get(8)?, + open_count: row.get(9)?, + opened_at: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + Ok(files) + }) + .await + } + pub async fn load_saved_sql_library_summary(&self) -> Result { self.with_conn(|conn| { let mut folder_stmt = conn @@ -5658,6 +5693,12 @@ mod tests { assert_eq!(loaded.sql, file.sql); assert_eq!(loaded.catalog.as_deref(), Some("hive")); assert!(loaded.sql_loaded); + + let sync_files = storage.load_saved_sql_files_for_sync().await.unwrap(); + assert_eq!(sync_files.len(), 1); + assert_eq!(sync_files[0].id, file.id); + assert_eq!(sync_files[0].sql, file.sql); + assert!(sync_files[0].sql_loaded); } #[tokio::test] diff --git a/src-tauri/src/commands/saved_sql.rs b/src-tauri/src/commands/saved_sql.rs index 9150647c5f..db9b68705d 100644 --- a/src-tauri/src/commands/saved_sql.rs +++ b/src-tauri/src/commands/saved_sql.rs @@ -1,6 +1,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::{collections::HashSet, io::ErrorKind}; +use std::{ + collections::{HashMap, HashSet}, + io::ErrorKind, +}; use sha2::{Digest, Sha256}; use tauri::State; @@ -61,6 +64,11 @@ pub async fn load_saved_sql_library(state: State<'_, Arc>) -> Result>) -> Result, String> { + state.storage.load_saved_sql_files_for_sync().await +} + #[tauri::command] pub async fn load_saved_sql_file(state: State<'_, Arc>, id: String) -> Result, String> { state.storage.load_saved_sql_file(&id).await @@ -125,40 +133,91 @@ pub async fn sync_saved_sql_directory(request: SavedSqlSyncRequest) -> Result<() fn sync_saved_sql_directory_blocking(target_dir: &Path, entries: &[SavedSqlSyncEntry]) -> Result<(), String> { let sync_root = target_dir.join("dbx-sql-library"); let previous_manifest = load_previous_sync_manifest(&sync_root)?; - verify_previous_sync_files(&sync_root, &previous_manifest.files)?; - std::fs::create_dir_all(&sync_root).map_err(|e| e.to_string())?; - if previous_manifest.exists { - std::fs::remove_file(sync_root.join(SYNC_MANIFEST_FILE)).map_err(|e| e.to_string())?; - } - remove_previous_sync_files(&sync_root, &previous_manifest.files)?; - - let mut written_files = Vec::new(); + let previous_by_path = + previous_manifest.files.iter().map(|file| (file.path.as_str(), file)).collect::>(); + let reusable_paths = previous_by_path.keys().copied().collect::>(); + let mut planned_paths = HashSet::new(); + let mut planned_files = Vec::with_capacity(entries.len()); for entry in entries { - let mut file_dir = sync_root.to_path_buf(); + let mut relative_dir = PathBuf::new(); if let Some(folder_name) = entry.folder_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) { for segment in folder_name.split('/') { let segment = segment.trim(); if !segment.is_empty() { - file_dir.push(sanitize_file_segment(segment)); + relative_dir.push(sanitize_file_segment(segment)); } } } - std::fs::create_dir_all(&file_dir).map_err(|e| e.to_string())?; let file_name = ensure_sql_extension(&sanitize_file_segment(&entry.file_name)); - let file_path = unique_file_path(&file_dir, &file_name); - std::fs::write(&file_path, &entry.sql).map_err(|e| e.to_string())?; - if let Ok(relative) = file_path.strip_prefix(&sync_root) { - written_files.push(SavedSqlSyncManifestFile { - path: relative.to_string_lossy().replace('\\', "/"), - sha256: sha256_hex(entry.sql.as_bytes()), - }); + let relative = + unique_sync_relative_path(&sync_root, &relative_dir, &file_name, &reusable_paths, &planned_paths); + let path = relative.to_string_lossy().replace('\\', "/"); + planned_paths.insert(path.clone()); + planned_files + .push((SavedSqlSyncManifestFile { path, sha256: sha256_hex(entry.sql.as_bytes()) }, entry.sql.as_bytes())); + } + + let unchanged = previous_manifest.exists + && previous_manifest.files.len() == planned_files.len() + && planned_files.iter().all(|(planned, _)| { + previous_by_path + .get(planned.path.as_str()) + .is_some_and(|previous| previous.sha256.eq_ignore_ascii_case(&planned.sha256)) + }); + if unchanged { + return Ok(()); + } + + let planned_by_path = planned_files.iter().map(|(file, _)| (file.path.as_str(), file)).collect::>(); + verify_previous_sync_files( + &sync_root, + previous_manifest.files.iter().filter(|previous| { + planned_by_path + .get(previous.path.as_str()) + .is_none_or(|planned| !previous.sha256.eq_ignore_ascii_case(&planned.sha256)) + }), + )?; + + for (planned, _) in &planned_files { + if previous_by_path.contains_key(planned.path.as_str()) { + continue; + } + let relative = normalized_relative_path(&planned.path).expect("planned sync paths are normalized"); + match std::fs::symlink_metadata(sync_root.join(relative)) { + Err(error) if error.kind() == ErrorKind::NotFound => {} + Ok(_) => return Err(sync_conflict(&planned.path, "an unmanaged path appeared during synchronization")), + Err(error) => { + return Err(sync_conflict(&planned.path, &format!("target path cannot be inspected: {error}"))) + } } } - let manifest = SavedSqlSyncManifest { version: SYNC_MANIFEST_VERSION, files: written_files }; + let manifest = SavedSqlSyncManifest { + version: SYNC_MANIFEST_VERSION, + files: planned_files + .iter() + .map(|(file, _)| SavedSqlSyncManifestFile { path: file.path.clone(), sha256: file.sha256.clone() }) + .collect(), + }; let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?; + + std::fs::create_dir_all(&sync_root).map_err(|e| e.to_string())?; + + for (planned, contents) in &planned_files { + if previous_by_path + .get(planned.path.as_str()) + .is_some_and(|previous| previous.sha256.eq_ignore_ascii_case(&planned.sha256)) + { + continue; + } + let relative = normalized_relative_path(&planned.path).expect("planned sync paths are normalized"); + let file_path = sync_root.join(relative); + write_sync_file(&file_path, contents, previous_by_path.contains_key(planned.path.as_str()))?; + } + + remove_stale_sync_files(&sync_root, &previous_manifest.files, &planned_paths)?; write_sync_manifest(&sync_root, manifest_json.as_bytes()) } @@ -207,7 +266,10 @@ fn validate_manifest_files(files: &[SavedSqlSyncManifestFile]) -> Result<(), Str Ok(()) } -fn verify_previous_sync_files(target_dir: &Path, files: &[SavedSqlSyncManifestFile]) -> Result<(), String> { +fn verify_previous_sync_files<'a>( + target_dir: &Path, + files: impl IntoIterator, +) -> Result<(), String> { for file in files { let relative = normalized_relative_path(&file.path).expect("manifest paths were validated"); let file_path = target_dir.join(relative); @@ -225,8 +287,15 @@ fn verify_previous_sync_files(target_dir: &Path, files: &[SavedSqlSyncManifestFi Ok(()) } -fn remove_previous_sync_files(target_dir: &Path, files: &[SavedSqlSyncManifestFile]) -> Result<(), String> { +fn remove_stale_sync_files( + target_dir: &Path, + files: &[SavedSqlSyncManifestFile], + planned_paths: &HashSet, +) -> Result<(), String> { for file in files { + if planned_paths.contains(&file.path) { + continue; + } let relative = normalized_relative_path(&file.path).expect("manifest paths were validated"); let file_path = target_dir.join(relative); std::fs::remove_file(&file_path).map_err(|e| e.to_string())?; @@ -261,12 +330,47 @@ fn sync_conflict(path: &str, reason: &str) -> String { format!("Saved SQL sync conflict for '{path}': {reason}; no files were changed") } +fn write_sync_file(file_path: &Path, contents: &[u8], replace: bool) -> Result<(), String> { + let parent = file_path.parent().ok_or_else(|| "Saved SQL sync target has no parent directory".to_string())?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let temporary_path = parent.join(format!(".dbx-sql-sync.{}.tmp", uuid::Uuid::new_v4())); + if let Err(error) = std::fs::write(&temporary_path, contents) { + return Err(error.to_string()); + } + + #[cfg(target_os = "windows")] + if replace { + if let Err(error) = std::fs::remove_file(file_path) { + let _ = std::fs::remove_file(&temporary_path); + return Err(error.to_string()); + } + } + + #[cfg(not(target_os = "windows"))] + let _ = replace; + + if let Err(error) = std::fs::rename(&temporary_path, file_path) { + let _ = std::fs::remove_file(temporary_path); + return Err(error.to_string()); + } + Ok(()) +} + fn write_sync_manifest(target_dir: &Path, contents: &[u8]) -> Result<(), String> { let manifest_path = target_dir.join(SYNC_MANIFEST_FILE); let temporary_path = target_dir.join(format!(".{SYNC_MANIFEST_FILE}.{}.tmp", uuid::Uuid::new_v4())); if let Err(error) = std::fs::write(&temporary_path, contents) { return Err(error.to_string()); } + + #[cfg(target_os = "windows")] + if manifest_path.exists() { + if let Err(error) = std::fs::remove_file(&manifest_path) { + let _ = std::fs::remove_file(&temporary_path); + return Err(error.to_string()); + } + } + if let Err(error) = std::fs::rename(&temporary_path, manifest_path) { let _ = std::fs::remove_file(temporary_path); return Err(error.to_string()); @@ -313,17 +417,25 @@ fn ensure_sql_extension(name: &str) -> String { } } -fn unique_file_path(dir: &Path, file_name: &str) -> PathBuf { - let mut candidate = dir.join(file_name); - if !candidate.exists() { - return candidate; - } - +fn unique_sync_relative_path( + sync_root: &Path, + relative_dir: &Path, + file_name: &str, + reusable_paths: &HashSet<&str>, + planned_paths: &HashSet, +) -> PathBuf { let base = file_name.strip_suffix(".sql").unwrap_or(file_name); - let mut counter = 2; + let mut counter = 1; loop { - candidate = dir.join(format!("{base} ({counter}).sql")); - if !candidate.exists() { + let candidate = if counter == 1 { + relative_dir.join(file_name) + } else { + relative_dir.join(format!("{base} ({counter}).sql")) + }; + let relative = candidate.to_string_lossy().replace('\\', "/"); + if !planned_paths.contains(&relative) + && (reusable_paths.contains(relative.as_str()) || !sync_root.join(&candidate).exists()) + { return candidate; } counter += 1; @@ -414,6 +526,84 @@ mod tests { assert!(!root.join("reports/daily (2).sql").exists()); } + #[test] + fn unchanged_large_sync_keeps_managed_file_identity() { + let target = TestDirectory::new("unchanged-identity"); + let entries = (0..128) + .map(|index| named_entry(&format!("query-{index}"), &format!("SELECT {index};"))) + .collect::>(); + sync_saved_sql_directory_blocking(&target.0, &entries).unwrap(); + + let managed_file = sync_root(&target).join("reports/query-64.sql"); + let hard_link = target.0.join("daily-hard-link.sql"); + std::fs::hard_link(&managed_file, &hard_link).unwrap(); + + sync_saved_sql_directory_blocking(&target.0, &entries).unwrap(); + std::fs::write(&hard_link, "SELECT 'same inode';").unwrap(); + + assert_eq!(std::fs::read_to_string(managed_file).unwrap(), "SELECT 'same inode';"); + } + + #[test] + fn writes_only_new_and_changed_managed_files() { + let target = TestDirectory::new("incremental-write"); + sync_saved_sql_directory_blocking( + &target.0, + &[named_entry("daily", "SELECT 1;"), named_entry("weekly", "SELECT 7;")], + ) + .unwrap(); + + let root = sync_root(&target); + let unchanged_file = root.join("reports/weekly.sql"); + let hard_link = target.0.join("weekly-hard-link.sql"); + std::fs::hard_link(&unchanged_file, &hard_link).unwrap(); + + sync_saved_sql_directory_blocking( + &target.0, + &[ + named_entry("daily", "SELECT 2;"), + named_entry("weekly", "SELECT 7;"), + named_entry("monthly", "SELECT 30;"), + ], + ) + .unwrap(); + + assert_eq!(std::fs::read_to_string(root.join("reports/daily.sql")).unwrap(), "SELECT 2;"); + assert_eq!(std::fs::read_to_string(root.join("reports/monthly.sql")).unwrap(), "SELECT 30;"); + std::fs::write(&hard_link, "SELECT 'same weekly inode';").unwrap(); + assert_eq!(std::fs::read_to_string(unchanged_file).unwrap(), "SELECT 'same weekly inode';"); + } + + #[test] + fn move_rename_and_delete_remove_only_stale_managed_paths() { + let target = TestDirectory::new("move-rename-delete"); + sync_saved_sql_directory_blocking( + &target.0, + &[named_entry("daily", "SELECT 1;"), named_entry("weekly", "SELECT 7;")], + ) + .unwrap(); + + let root = sync_root(&target); + let unmanaged_file = root.join("reports/notes.txt"); + std::fs::write(&unmanaged_file, "keep me").unwrap(); + let moved = SavedSqlSyncEntry { + folder_name: Some("archive".to_string()), + file_name: "renamed".to_string(), + sql: "SELECT 1;".to_string(), + }; + + sync_saved_sql_directory_blocking(&target.0, &[moved]).unwrap(); + + assert!(!root.join("reports/daily.sql").exists()); + assert!(!root.join("reports/weekly.sql").exists()); + assert_eq!(std::fs::read_to_string(root.join("archive/renamed.sql")).unwrap(), "SELECT 1;"); + assert_eq!(std::fs::read_to_string(unmanaged_file).unwrap(), "keep me"); + let manifest: SavedSqlSyncManifest = + serde_json::from_slice(&std::fs::read(root.join(SYNC_MANIFEST_FILE)).unwrap()).unwrap(); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "archive/renamed.sql"); + } + #[test] fn rejects_external_edits_before_overwriting_managed_files() { let target = TestDirectory::new("external-edit"); @@ -427,6 +617,22 @@ mod tests { assert_eq!(std::fs::read_to_string(managed_file).unwrap(), "SELECT 'edited outside DBX';"); } + #[test] + fn unchanged_external_edit_is_left_untouched_until_dbx_needs_the_path() { + let target = TestDirectory::new("external-edit-unchanged"); + sync_saved_sql_directory_blocking(&target.0, &[entry("SELECT 1;")]).unwrap(); + + let managed_file = target.0.join("dbx-sql-library/reports/daily.sql"); + std::fs::write(&managed_file, "SELECT 'edited outside DBX';").unwrap(); + + sync_saved_sql_directory_blocking(&target.0, &[entry("SELECT 1;")]).unwrap(); + assert_eq!(std::fs::read_to_string(&managed_file).unwrap(), "SELECT 'edited outside DBX';"); + + let error = sync_saved_sql_directory_blocking(&target.0, &[entry("SELECT 2;")]).unwrap_err(); + assert!(error.contains("managed file was edited outside DBX"), "unexpected error: {error}"); + assert_eq!(std::fs::read_to_string(managed_file).unwrap(), "SELECT 'edited outside DBX';"); + } + #[test] fn rejects_missing_managed_file_without_changing_siblings() { let target = TestDirectory::new("external-delete"); @@ -528,4 +734,26 @@ mod tests { .count(); assert_eq!(temporary_files, 0); } + + #[test] + fn failed_managed_file_publish_keeps_destination_and_cleans_temporary_file() { + let target = TestDirectory::new("managed-file-publish"); + let destination = target.0.join("daily.sql"); + std::fs::create_dir(&destination).unwrap(); + std::fs::write(destination.join("keep.txt"), "keep me").unwrap(); + + let error = write_sync_file(&destination, b"SELECT 2;", true).unwrap_err(); + assert!(!error.is_empty()); + assert_eq!(std::fs::read_to_string(destination.join("keep.txt")).unwrap(), "keep me"); + let temporary_files = std::fs::read_dir(&target.0) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(".dbx-sql-sync.") && name.ends_with(".tmp") + }) + .count(); + assert_eq!(temporary_files, 0); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2166683367..a62f4df447 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1961,6 +1961,7 @@ pub fn run() { commands::nacos_cmd::nacos_preview_config_transfer, commands::nacos_cmd::nacos_apply_config_transfer, commands::saved_sql::load_saved_sql_library, + commands::saved_sql::load_saved_sql_files_for_sync, commands::saved_sql::load_saved_sql_file, commands::saved_sql::save_saved_sql_folder, commands::saved_sql::delete_saved_sql_folder,