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
}
}
+42 -42
View File
@@ -48,48 +48,6 @@
<key>uniqueIdentifier</key>
<string>2FC9B29D-EE21-4701-8634-B8C65E3A79A5</string>
</dict>
<key>68955E3A-0B2E-4614-AC89-290DCD28B6D0</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>68955E3A-0B2E-4614-AC89-290DCD28B6D0.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>WorkbenchReplacement project</string>
<key>schemeIdentifier-schemeName</key>
<string>WorkbenchReplacement</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813877338.21300805</real>
<key>timeStoppedRecording</key>
<real>813877351.33591294</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>68955E3A-0B2E-4614-AC89-290DCD28B6D0</string>
</dict>
<key>B2C949DC-0503-4157-BC15-F74641625B50</key>
<dict>
<key>className</key>
@@ -132,6 +90,48 @@
<key>uniqueIdentifier</key>
<string>B2C949DC-0503-4157-BC15-F74641625B50</string>
</dict>
<key>D705A532-F8F7-4B72-94B4-3464B760E3E3</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>D705A532-F8F7-4B72-94B4-3464B760E3E3.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>WorkbenchReplacement project</string>
<key>schemeIdentifier-schemeName</key>
<string>WorkbenchReplacement</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813878073.32850504</real>
<key>timeStoppedRecording</key>
<real>813878087.86567605</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>D705A532-F8F7-4B72-94B4-3464B760E3E3</string>
</dict>
<key>EB994874-BDA8-4BAC-BAF2-6EB6F20E2468</key>
<dict>
<key>className</key>
@@ -1 +1 @@
1792185059.890316: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
1792185273.801373: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastAccessedDate</key>
<date>2026-10-16T21:10:59Z</date>
<date>2026-10-16T21:14:33Z</date>
<key>WorkspacePath</key>
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
</dict>
+10
View File
@@ -0,0 +1,10 @@
# Phase 12 — Restore Validation
- Exports now create `<backup>.sql.manifest.json` alongside SQL and SHA-256 files.
- The manifest records the selected database, table names and snapshot row counts, view names, trigger names, and routine names/types.
- **Verify Restored Database…** selects an SQL backup and checks its manifest against the selected live database. It optionally verifies the SQL SHA-256 sidecar before checking.
- Reports mismatched table row counts, missing or inaccessible views/triggers/routines, and successful checks. Validation is read-only.
- Structure-only exports record a `null` row count; row-count comparison is skipped for those tables.
- This does **not** validate row content, schema definitions, or guarantee a complete/restorable backup. Do not use as a substitute for a test restore and independent backups.
- Requires appropriate MySQL privileges for SHOW CREATE operations. No external utilities.
- Full Xcode build and live-server tests have not been performed.