diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 5978c993a6..0c2e84eabe 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -152,6 +152,17 @@ function joinExportPath(directory: string, fileName: string): string { return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`; } +// Lenient exports write per-object failures into the SQL file as `-- ERROR` +// comments and still finish; completion must warn instead of reporting plain +// success (#8184). +function toastDatabaseExportCompletion(errorCount: number, errorSummary: string | null) { + if (errorCount > 0) { + toast(t("databaseExport.exportSuccessWithErrors", { count: errorCount, firstError: errorSummary ?? "" }), 8000); + return; + } + toast(t("databaseExport.exportSuccess"), 3000); +} + async function loadDatabases(connId: string) { if (!connId) return; loadingMeta.value = true; @@ -370,7 +381,7 @@ async function startExport() { finishExportTiming(); exportDone.value = true; isExporting.value = false; - toast(t("databaseExport.exportSuccess"), 3000); + toastDatabaseExportCompletion(progress.errorCount ?? 0, progress.errorSummary ?? null); } else if (progress.status === "Error") { finishExportTiming(); exportError.value = progress.error; @@ -432,6 +443,8 @@ async function startAllDatabasesExport() { exportCancelled.value = false; batchDatabaseIndex.value = 0; batchRowsExported.value = 0; + let batchLenientErrorCount = 0; + let batchFirstErrorSummary: string | null = null; const dbs = [...selectedDatabases.value]; const connectionType = store.getConfig(connectionId.value)?.db_type; @@ -475,7 +488,7 @@ async function startAllDatabasesExport() { const filePath = isTauriRuntime() ? joinExportPath(directoryPath, `${sanitizeFileName(item.fileStem)}.sql`) : `__web_export_${currentExportId}.sql`; let currentDatabaseRowsExported = 0; - await runWithDatabaseBackupSnapshot( + const terminal = await runWithDatabaseBackupSnapshot( { connectionId: connectionId.value, database: item.database, @@ -530,6 +543,8 @@ async function startAllDatabasesExport() { (terminal) => terminal.status === "Done", ); + batchLenientErrorCount += terminal.errorCount ?? 0; + batchFirstErrorSummary ??= terminal.errorSummary ?? null; if (exportError.value || exportCancelled.value) break; activeDatabaseExportId.value = ""; } @@ -547,10 +562,16 @@ async function startAllDatabasesExport() { totalRows: null, status: "Done", error: null, + errorCount: batchLenientErrorCount, + errorSummary: batchFirstErrorSummary, }; exportProgress.value = finalProgress; updateDatabaseExportTask(batchId, finalProgress); - toast(t("databaseExport.exportAllSuccess", { count: dbs.length }), 3000); + if (batchLenientErrorCount > 0) { + toast(t("databaseExport.exportAllSuccessWithErrors", { count: dbs.length, errorCount: batchLenientErrorCount, firstError: batchFirstErrorSummary ?? "" }), 8000); + } else { + toast(t("databaseExport.exportAllSuccess", { count: dbs.length }), 3000); + } } } catch (e: any) { exportError.value = e?.message || String(e); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ce69992f36..c2a29723f1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7560,6 +7560,8 @@ export default { allRowsExported: "Total rows exported: {count}", exportSuccess: "Database exported successfully", exportAllSuccess: "{count} databases exported successfully", + exportSuccessWithErrors: "Export finished with {count} failed object(s): {firstError}… See the -- ERROR comments inside the exported file for details.", + exportAllSuccessWithErrors: "{count} databases exported, but {errorCount} object(s) failed: {firstError}… See the -- ERROR comments inside the exported files.", allDatabasesTask: "All databases ({count})", exportError: "Export failed: {error}", exportCancelled: "Export cancelled", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c7e3216f6d..7375aa55f8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7214,6 +7214,8 @@ export default withEnglishFallback({ allRowsExported: "Total de filas exportadas: {count}", exportSuccess: "Base de datos exportada exitosamente", exportAllSuccess: "{count} bases de datos exportadas exitosamente", + exportSuccessWithErrors: "La exportación terminó con {count} objeto(s) fallidos: {firstError}… Consulta los comentarios -- ERROR en el archivo exportado.", + exportAllSuccessWithErrors: "Se exportaron {count} bases de datos, pero {errorCount} objeto(s) fallaron: {firstError}… Consulta los comentarios -- ERROR en los archivos.", allDatabasesTask: "Todas las bases de datos ({count})", exportError: "Error al exportar: {error}", exportCancelled: "Exportación cancelada", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9f3136de5f..19de015e70 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7216,6 +7216,8 @@ export default withEnglishFallback({ allRowsExported: "Totale righe esportate: {count}", exportSuccess: "Database esportato con successo", exportAllSuccess: "{count} database esportati con successo", + exportSuccessWithErrors: "Esportazione completata con {count} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nel file esportato.", + exportAllSuccessWithErrors: "Esportati {count} database, ma {errorCount} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nei file.", allDatabasesTask: "Tutti i database ({count})", exportError: "Esportazione non riuscita: {error}", exportCancelled: "Esportazione annullata", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 92e080f2b3..48d3023d50 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7268,6 +7268,8 @@ export default withEnglishFallback({ allRowsExported: "累計エクスポート行数: {count}", exportSuccess: "データベースを正常にエクスポートしました", exportAllSuccess: "{count}個のデータベースを正常にエクスポートしました", + exportSuccessWithErrors: "エクスポートは完了しましたが、{count} 個のオブジェクトが失敗しました:{firstError}… 詳細はエクスポートファイル内の -- ERROR コメントを確認してください。", + exportAllSuccessWithErrors: "{count} 個のデータベースをエクスポートしましたが、{errorCount} 個のオブジェクトが失敗しました:{firstError}… 詳細はファイル内の -- ERROR コメントを確認してください。", allDatabasesTask: "すべてのデータベース ({count})", exportError: "エクスポートに失敗しました: {error}", exportCancelled: "エクスポートがキャンセルされました", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e389dad264..b3d7f1da12 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6924,6 +6924,8 @@ export default withEnglishFallback({ allRowsExported: "총 내보낸 행: {count}", exportSuccess: "데이터베이스를 성공적으로 내보냈습니다", exportAllSuccess: "데이터베이스 {count}개를 성공적으로 내보냈습니다", + exportSuccessWithErrors: "내보내기가 완료되었지만 {count}개 객체가 실패했습니다: {firstError}… 자세한 내용은 내보낸 파일의 -- ERROR 주석을 참조하세요.", + exportAllSuccessWithErrors: "데이터베이스 {count}개를 내보냈지만 {errorCount}개 객체가 실패했습니다: {firstError}… 자세한 내용은 파일의 -- ERROR 주석을 참조하세요.", allDatabasesTask: "모든 데이터베이스 ({count})", exportError: "내보내기 실패: {error}", exportCancelled: "내보내기 취소됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bf4fd1254b..b2506ffb24 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7218,6 +7218,8 @@ export default withEnglishFallback({ allRowsExported: "Total de linhas exportadas: {count}", exportSuccess: "Banco de dados exportado com sucesso", exportAllSuccess: "{count} bancos de dados exportados com sucesso", + exportSuccessWithErrors: "A exportação terminou com {count} objeto(s) com falha: {firstError}… Veja os comentários -- ERROR no arquivo exportado.", + exportAllSuccessWithErrors: "{count} bancos de dados exportados, mas {errorCount} objeto(s) falharam: {firstError}… Veja os comentários -- ERROR nos arquivos.", allDatabasesTask: "Todos os bancos de dados ({count})", exportError: "Falha na exportação: {error}", exportCancelled: "Exportação cancelada", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 3cf9b207d9..a1e54514f6 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7550,6 +7550,8 @@ export default withEnglishFallback({ allRowsExported: "Dışa aktarılan toplam satır: {count}", exportSuccess: "Veritabanı başarıyla dışa aktarıldı", exportAllSuccess: "{count} veritabanı başarıyla dışa aktarıldı", + exportSuccessWithErrors: "Dışa aktarma tamamlandı ancak {count} nesne başarısız oldu: {firstError}… Ayrıntılar için dışa aktarılan dosyadaki -- ERROR yorumlarına bakın.", + exportAllSuccessWithErrors: "{count} veritabanı dışa aktarıldı ancak {errorCount} nesne başarısız oldu: {firstError}… Ayrıntılar için dosyalardaki -- ERROR yorumlarına bakın.", allDatabasesTask: "Tüm veritabanları ({count})", exportError: "Dışa aktarma başarısız: {error}", exportCancelled: "Dışa aktarma iptal edildi", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bcf2ca5c49..038d58e75b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7541,6 +7541,8 @@ export default withEnglishFallback({ allRowsExported: "累计已导出 {count} 行", exportSuccess: "数据库导出成功", exportAllSuccess: "已成功导出 {count} 个数据库", + exportSuccessWithErrors: "导出完成,但有 {count} 个对象失败:{firstError}……详情见导出文件中的 -- ERROR 注释。", + exportAllSuccessWithErrors: "已导出 {count} 个数据库,但有 {errorCount} 个对象失败:{firstError}……详情见导出文件中的 -- ERROR 注释。", allDatabasesTask: "全部数据库({count})", exportError: "导出失败: {error}", exportCancelled: "导出已取消", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 15219cf8df..18ab8c32c4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6531,6 +6531,8 @@ export default withEnglishFallback({ allRowsExported: "累計已匯出 {count} 列", exportSuccess: "資料庫匯出成功", exportAllSuccess: "已成功匯出 {count} 個資料庫", + exportSuccessWithErrors: "匯出完成,但有 {count} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。", + exportAllSuccessWithErrors: "已匯出 {count} 個資料庫,但有 {errorCount} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。", allDatabasesTask: "全部資料庫({count})", exportError: "匯出失敗: {error}", exportCancelled: "匯出已取消", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 498d9a052a..b1dd2e4d1e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -4825,6 +4825,10 @@ export interface ExportProgress { error: string | null; /** True while listing schema / prefetching metadata before objects are written. */ preparing?: boolean; + /** Per-object failures written into the file as `-- ERROR` comments (lenient mode). */ + errorCount?: number; + /** First lenient failure, for completion warnings without opening the file. */ + errorSummary?: string | null; } // --- Table Export --- diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 388efcf366..d16dd35173 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -268,6 +268,32 @@ pub struct ExportProgress { /// True while listing schema / prefetching table metadata — before objects are written. #[serde(default)] pub preparing: bool, + /// Per-object failures written into the file as `-- ERROR` comments in + /// lenient mode. Strict mode fails the whole export instead, so this stays + /// zero. Without it a partially failed export reports plain success and + /// the errors are only discoverable by opening the file (#8184). + #[serde(default)] + pub error_count: u64, + /// First lenient failure, so completion surfaces can show what went wrong + /// without opening the exported file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_summary: Option, +} + +/// Collects lenient per-object export failures for the terminal progress. +#[derive(Default)] +struct LenientExportErrors { + count: usize, + first: Option, +} + +impl LenientExportErrors { + fn record(&mut self, message: String) { + if self.first.is_none() { + self.first = Some(message.clone()); + } + self.count += 1; + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1972,10 +1998,16 @@ fn database_export_metadata_prefetch_concurrency(db_type: DatabaseType) -> usize } } -fn record_export_error(file: &mut W, fail_on_error: bool, message: String) -> Result<(), String> { +fn record_export_error( + file: &mut W, + fail_on_error: bool, + message: String, + lenient_errors: &mut LenientExportErrors, +) -> Result<(), String> { if fail_on_error { Err(message) } else { + lenient_errors.record(message.clone()); writeln!(file, "-- ERROR {message}").map_err(|error| format!("Failed to write file: {error}")) } } @@ -2139,6 +2171,8 @@ fn emit_database_export_running( status: ExportStatus::Running, error: None, preparing, + error_count: 0, + error_summary: None, }); } @@ -2160,6 +2194,8 @@ fn emit_database_export_cancelled( status: ExportStatus::Cancelled, error: None, preparing: false, + error_count: 0, + error_summary: None, }); } @@ -2534,6 +2570,7 @@ async fn export_database_sql_core_inner( } else { None }; + let mut lenient_errors = LenientExportErrors::default(); // Emit immediately so the UI is never blank while we list schema metadata. emit_database_export_running(&on_progress, &request.export_id, "", 0, 0, 0, true); @@ -2632,7 +2669,12 @@ async fn export_database_sql_core_inner( match list_postgres_extension_members(state, &pool_key, &request.schema).await { Ok(members) => members, Err(e) => { - record_export_error(&mut file, request.fail_on_error, format!("reading extension members: {e}"))?; + record_export_error( + &mut file, + request.fail_on_error, + format!("reading extension members: {e}"), + &mut lenient_errors, + )?; PostgresExtensionMembers::default() } } @@ -2656,7 +2698,12 @@ async fn export_database_sql_core_inner( }) .collect(), Err(e) => { - record_export_error(&mut file, request.fail_on_error, format!("exporting extensions: {e}"))?; + record_export_error( + &mut file, + request.fail_on_error, + format!("exporting extensions: {e}"), + &mut lenient_errors, + )?; Vec::new() } } @@ -2698,7 +2745,12 @@ async fn export_database_sql_core_inner( Ok(sequences) => sequences, Err(e) if e == EXPORT_CANCELLED_ERROR => return Err(EXPORT_CANCELLED_ERROR.to_string()), Err(e) => { - record_export_error(&mut file, request.fail_on_error, format!("exporting sequences: {e}"))?; + record_export_error( + &mut file, + request.fail_on_error, + format!("exporting sequences: {e}"), + &mut lenient_errors, + )?; Vec::new() } } @@ -2972,6 +3024,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); // Export structure @@ -2994,6 +3048,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); writeln!(file, "{};\n", generate_postgres_sequence_create_ddl(sequence, &request.schema)) @@ -3040,6 +3096,7 @@ async fn export_database_sql_core_inner( &mut file, request.fail_on_error, format!("exporting table structure {table_name}: {e}"), + &mut lenient_errors, )?; } } @@ -3079,6 +3136,7 @@ async fn export_database_sql_core_inner( &mut file, request.fail_on_error, format!("exporting columns for table {table_name}: {e}"), + &mut lenient_errors, )?; object_index += 1; continue; @@ -3128,6 +3186,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); Ok(()) }, @@ -3195,6 +3255,7 @@ async fn export_database_sql_core_inner( &mut file, request.fail_on_error, format!("exporting data for table {table_name}: {error}"), + &mut lenient_errors, )?; break; } @@ -3232,6 +3293,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); if row_count < batch_size { break; @@ -3276,6 +3339,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); match crate::schema::get_object_source_core( @@ -3303,7 +3368,12 @@ async fn export_database_sql_core_inner( } } Err(e) => { - record_export_error(&mut file, request.fail_on_error, format!("exporting view {view_name}: {e}"))?; + record_export_error( + &mut file, + request.fail_on_error, + format!("exporting view {view_name}: {e}"), + &mut lenient_errors, + )?; } } @@ -3328,6 +3398,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); match crate::schema::get_object_source_core( @@ -3359,6 +3431,7 @@ async fn export_database_sql_core_inner( &mut file, request.fail_on_error, format!("exporting procedure {proc_name}: {e}"), + &mut lenient_errors, )?; } } @@ -3384,6 +3457,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Running, error: None, preparing: false, + error_count: 0, + error_summary: None, }); match crate::schema::get_object_source_core( @@ -3415,6 +3490,7 @@ async fn export_database_sql_core_inner( &mut file, request.fail_on_error, format!("exporting function {func_name}: {e}"), + &mut lenient_errors, )?; } } @@ -3450,6 +3526,8 @@ async fn export_database_sql_core_inner( status: ExportStatus::Done, error: None, preparing: false, + error_count: lenient_errors.count as u64, + error_summary: lenient_errors.first.clone(), }); Ok(()) @@ -3518,6 +3596,7 @@ mod tests { DdlNormalizeOptions, ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, }; + use super::{ExportProgress, LenientExportErrors}; use crate::connection::AppState; use crate::models::connection::DatabaseType; use crate::storage::Storage; @@ -5407,14 +5486,73 @@ mod tests { let path = std::env::temp_dir().join(format!("dbx-strict-export-{}.sql", uuid::Uuid::new_v4())); let mut file = std::fs::File::create(&path).unwrap(); - let result = record_export_error(&mut file, true, "exporting table users: permission denied".to_string()); + let mut lenient_errors = LenientExportErrors::default(); + let result = record_export_error( + &mut file, + true, + "exporting table users: permission denied".to_string(), + &mut lenient_errors, + ); drop(file); assert_eq!(result.unwrap_err(), "exporting table users: permission denied"); + assert_eq!(lenient_errors.count, 0); assert!(std::fs::read_to_string(&path).unwrap().is_empty()); let _ = std::fs::remove_file(path); } + #[test] + fn lenient_export_errors_track_count_and_first_failure_for_terminal_progress() { + let path = std::env::temp_dir().join(format!("dbx-lenient-export-{}.sql", uuid::Uuid::new_v4())); + let mut file = std::fs::File::create(&path).unwrap(); + + let mut lenient_errors = LenientExportErrors::default(); + record_export_error( + &mut file, + false, + "exporting table orders: RPC call timed out".to_string(), + &mut lenient_errors, + ) + .unwrap(); + record_export_error( + &mut file, + false, + "exporting view active_users: RPC call timed out".to_string(), + &mut lenient_errors, + ) + .unwrap(); + drop(file); + + // The terminal Done progress must carry enough information to warn + // instead of reporting plain success (#8184). + assert_eq!(lenient_errors.count, 2); + assert_eq!(lenient_errors.first.as_deref(), Some("exporting table orders: RPC call timed out")); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("-- ERROR exporting table orders: RPC call timed out")); + assert!(contents.contains("-- ERROR exporting view active_users: RPC call timed out")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn export_progress_deserializes_events_without_lenient_error_fields() { + // Progress events persisted by older versions lack errorCount / + // errorSummary; they must keep deserializing with zeroed defaults. + let legacy = serde_json::from_value::(serde_json::json!({ + "exportId": "export-1", + "currentObject": "users", + "objectIndex": 3, + "totalObjects": 10, + "rowsExported": 120, + "totalRows": null, + "status": "Done", + "error": null + })) + .unwrap(); + + assert_eq!(legacy.error_count, 0); + assert_eq!(legacy.error_summary, None); + } + async fn test_app_state(scratch_dir: &std::path::Path) -> AppState { let storage = Storage::open(&scratch_dir.join("storage.db")).await.unwrap(); AppState::new(storage) diff --git a/crates/dbx-web/src/routes/database_export.rs b/crates/dbx-web/src/routes/database_export.rs index 0950f3897e..b3009bc663 100644 --- a/crates/dbx-web/src/routes/database_export.rs +++ b/crates/dbx-web/src/routes/database_export.rs @@ -105,6 +105,8 @@ pub async fn start_database_export( status: ExportStatus::Error, error: Some(e.clone()), preparing: false, + error_count: 0, + error_summary: None, }; if let Ok(json) = serde_json::to_string(&progress) { let _ = tx.send(json); diff --git a/src-tauri/src/commands/database_export.rs b/src-tauri/src/commands/database_export.rs index 6a098d690b..c2908d1126 100644 --- a/src-tauri/src/commands/database_export.rs +++ b/src-tauri/src/commands/database_export.rs @@ -59,6 +59,8 @@ pub async fn export_database_sql( status: ExportStatus::Error, error: Some(e), preparing: false, + error_count: 0, + error_summary: None, }, ); }