Improved progress display

Previously, the progress bar updated only after completing an entire table.

For large tables such as LIBARC, this could make the application appear to have stopped responding.

Phase 10 updates the interface while rows are being exported.
This commit is contained in:
Mark Gregory
2026-10-16 22:14:53 +01:00
parent e177b1d1ea
commit 9c5c0b5a91
11 changed files with 223 additions and 45 deletions
+169 -1
View File
@@ -1589,6 +1589,8 @@ struct WorkbenchDatabaseTransferView: View {
@State private var loadingTables = false
@State private var availableDatabases: [String] = []
@State private var loadingDatabases = false
@State private var lastBackupURL: URL?
@State private var lastVerificationReport = ""
private var selectedProfile: ConnectionProfile? {
model.selected
@@ -1710,6 +1712,12 @@ struct WorkbenchDatabaseTransferView: View {
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
if !lastVerificationReport.isEmpty {
Text(lastVerificationReport)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
if isWorking && selectedMode == 0 && currentTableExpected > 0 {
ProgressView(value: Double(currentTableRows), total: Double(currentTableExpected))
Text("Current table: \(currentTableRows.formatted()) / \(currentTableExpected.formatted()) rows · Total exported: \(transferredRows.formatted())")
@@ -1738,6 +1746,11 @@ struct WorkbenchDatabaseTransferView: View {
chooseBackupToVerify()
}
.help("Verify an SQL file against its SHA-256 checksum sidecar")
Button("Verify Restored Database…") {
chooseBackupToValidate()
}
.disabled(selectedProfile == nil || database.isEmpty)
.help("Compare restored table row counts and object names with the export manifest")
}
if isWorking {
@@ -1864,6 +1877,91 @@ struct WorkbenchDatabaseTransferView: View {
}
}
private func chooseBackupToValidate() {
let panel = NSOpenPanel()
panel.title = "Select Exported SQL Backup to Validate"
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK, let url = panel.url else { return }
operationTask = Task { await validateRestoredDatabase(from: url) }
}
@MainActor
private func validateRestoredDatabase(from url: URL) async {
isWorking = true
progress = 0
lastVerificationReport = ""
defer { isWorking = false; operationTask = nil }
do {
let manifest = try WorkbenchBackupManifest.read(for: url)
guard manifest.database == database else {
throw WorkbenchTransferError.message(
"Manifest is for database \(manifest.database), but \(database) is selected."
)
}
if WorkbenchBackupIntegrity.hasSidecar(for: url) {
status = "Verifying SQL checksum…"
try await Task.detached(priority: .utility) {
try WorkbenchBackupIntegrity.verify(url)
}.value
}
var failures: [String] = []
var checked = 0
let total = manifest.tables.count + manifest.views.count + manifest.triggers.count + manifest.routines.count
for table in manifest.tables {
try Task.checkCancellation()
status = "Validating table \(table.name)"
do {
let rows = try await execute("SELECT COUNT(*) FROM \(quoteIdentifier(database)).\(quoteIdentifier(table.name))")
guard let actual = rows.rows.first?.first.flatMap(Int64.init) else {
failures.append("\(table.name): count unavailable")
continue
}
if let expected = table.rows, actual != expected {
failures.append("\(table.name): expected \(expected), found \(actual) rows")
}
} catch {
failures.append("\(table.name): \(error.localizedDescription)")
}
checked += 1
progress = Double(checked) / Double(max(total, 1))
}
for view in manifest.views {
try Task.checkCancellation()
status = "Validating view \(view)"
do { _ = try await execute("SHOW CREATE VIEW \(quoteIdentifier(database)).\(quoteIdentifier(view))") }
catch { failures.append("View \(view): \(error.localizedDescription)") }
checked += 1
progress = Double(checked) / Double(max(total, 1))
}
for trigger in manifest.triggers {
try Task.checkCancellation()
status = "Validating trigger \(trigger)"
do { _ = try await execute("SHOW CREATE TRIGGER \(quoteIdentifier(database)).\(quoteIdentifier(trigger))") }
catch { failures.append("Trigger \(trigger): \(error.localizedDescription)") }
checked += 1
progress = Double(checked) / Double(max(total, 1))
}
for routine in manifest.routines {
try Task.checkCancellation()
status = "Validating \(routine.kind) \(routine.name)"
do { _ = try await execute("SHOW CREATE \(routine.kind) \(quoteIdentifier(database)).\(quoteIdentifier(routine.name))") }
catch { failures.append("\(routine.kind) \(routine.name): \(error.localizedDescription)") }
checked += 1
progress = Double(checked) / Double(max(total, 1))
}
progress = 1
lastVerificationReport = failures.isEmpty
? "PASS: \(checked) objects checked. Counts and object existence match the manifest."
: "FAILED: \(failures.count) mismatches:\n" + failures.joined(separator: "\n")
status = failures.isEmpty ? "Restore validation passed" : "Restore validation found \(failures.count) problems"
} catch is CancellationError {
status = "Validation cancelled"
} catch {
status = "Validation failed: \(error.localizedDescription)"
}
}
private func chooseImportSource() {
let panel = NSOpenPanel()
panel.title = "Import MySQL SQL File"
@@ -1985,6 +2083,10 @@ struct WorkbenchDatabaseTransferView: View {
isWorking = true
progress = 0
transferredRows = 0
lastVerificationReport = ""
var manifestTables: [WorkbenchBackupManifest.Table] = []
var manifestRoutines: [WorkbenchBackupManifest.Routine] = []
var manifestTriggers: [String] = []
currentTableRows = 0
currentTableExpected = 0
let tables = availableTables.filter { selectedTables.contains($0) }
@@ -2143,8 +2245,12 @@ struct WorkbenchDatabaseTransferView: View {
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
)
}
manifestTables.append(.init(name: table, rows: written))
status = "Exported \(table): \(written) rows"
}
if !exportData {
manifestTables.append(.init(name: table, rows: nil))
}
currentTableExpected = 0
currentTableRows = 0
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
@@ -2192,6 +2298,7 @@ struct WorkbenchDatabaseTransferView: View {
try write("DELIMITER $$\n")
if includeDrop { try write("DROP \(kind) IF EXISTS \(qualified)$$\n") }
try write(row[2] + "$$\nDELIMITER ;\n")
manifestRoutines.append(.init(name: name, kind: kind))
}
}
if exportStructure && includeTriggers {
@@ -2216,6 +2323,7 @@ struct WorkbenchDatabaseTransferView: View {
try write("DELIMITER $$\n")
if includeDrop { try write("DROP TRIGGER IF EXISTS \(qualified)$$\n") }
try write(row[2] + "$$\nDELIMITER ;\n")
manifestTriggers.append(name)
}
}
try Task.checkCancellation()
@@ -2232,6 +2340,15 @@ struct WorkbenchDatabaseTransferView: View {
// Sidecar failure is reported; it does not delete a completed export.
do {
try WorkbenchBackupIntegrity.createSidecar(for: url)
let manifest = WorkbenchBackupManifest(
database: database,
tables: manifestTables,
views: views,
triggers: manifestTriggers,
routines: manifestRoutines
)
try manifest.write(for: url)
lastBackupURL = url
progress = 1
status = "Export complete and SHA-256 verified: \(tables.count) tables, \(views.count) views, \(transferredRows.formatted()) rows"
} catch {
@@ -2294,7 +2411,8 @@ struct WorkbenchDatabaseTransferView: View {
}
}
progress = 1
status = "Import complete: \(statementNumber) statements"
status = "Import complete: \(statementNumber) statements. Use Verify Restored Database to compare with the export manifest."
lastBackupURL = url
model.loadDatabases()
@@ -2627,3 +2745,53 @@ final class WorkbenchStreamingSQLReader {
if !statement.isEmpty { statement.append("\n") }
}
}
// MARK: - Phase 12 restore validation manifest
// This is a post-restore comparison, not a proof of byte-for-byte data equality.
struct WorkbenchBackupManifest: Codable {
struct Table: Codable {
let name: String
let rows: Int64? // nil when the export contains no table data
}
struct Routine: Codable {
let name: String
let kind: String
}
let version: Int
let database: String
let tables: [Table]
let views: [String]
let triggers: [String]
let routines: [Routine]
init(database: String, tables: [Table], views: [String], triggers: [String], routines: [Routine]) {
self.version = 1
self.database = database
self.tables = tables
self.views = views
self.triggers = triggers
self.routines = routines
}
static func sidecarURL(for sql: URL) -> URL {
URL(fileURLWithPath: sql.path + ".manifest.json")
}
func write(for sql: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(self).write(to: Self.sidecarURL(for: sql), options: .atomic)
}
static func read(for sql: URL) throws -> Self {
let url = sidecarURL(for: sql)
guard FileManager.default.fileExists(atPath: url.path) else {
throw WorkbenchTransferError.message("No .manifest.json sidecar found. Export a backup with Phase 12 first.")
}
let manifest = try JSONDecoder().decode(Self.self, from: Data(contentsOf: url))
guard manifest.version == 1 else {
throw WorkbenchTransferError.message("Unsupported backup manifest version \(manifest.version)")
}
return manifest
}
}