diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index e6e9583..8db8e29 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -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 + } +} diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors index 3efe3db..a7b8cc3 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement-primary.priors differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule index a23de6c..bf1efd1 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftmodule differ diff --git a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo index ccb2d20..12be0c5 100644 Binary files a/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo and b/build/Build/Intermediates.noindex/WorkbenchReplacement.build/Debug/WorkbenchReplacement.build/Objects-normal/arm64/WorkbenchReplacement.swiftsourceinfo differ diff --git a/build/Logs/Build/LogStoreManifest.plist b/build/Logs/Build/LogStoreManifest.plist index 05d847f..021c10a 100644 --- a/build/Logs/Build/LogStoreManifest.plist +++ b/build/Logs/Build/LogStoreManifest.plist @@ -48,48 +48,6 @@ uniqueIdentifier 2FC9B29D-EE21-4701-8634-B8C65E3A79A5 - 68955E3A-0B2E-4614-AC89-290DCD28B6D0 - - className - IDECommandLineBuildLog - documentTypeString - <nil> - domainType - Xcode.IDEActivityLogDomainType.BuildLog - fileName - 68955E3A-0B2E-4614-AC89-290DCD28B6D0.xcactivitylog - hasPrimaryLog - - primaryObservable - - highLevelStatus - S - totalNumberOfAnalyzerIssues - 0 - totalNumberOfErrors - 0 - totalNumberOfTestFailures - 0 - totalNumberOfWarnings - 0 - - schemeIdentifier-containerName - WorkbenchReplacement project - schemeIdentifier-schemeName - WorkbenchReplacement - schemeIdentifier-sharedScheme - 1 - signature - Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug - timeStartedRecording - 813877338.21300805 - timeStoppedRecording - 813877351.33591294 - title - Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug - uniqueIdentifier - 68955E3A-0B2E-4614-AC89-290DCD28B6D0 - B2C949DC-0503-4157-BC15-F74641625B50 className @@ -132,6 +90,48 @@ uniqueIdentifier B2C949DC-0503-4157-BC15-F74641625B50 + D705A532-F8F7-4B72-94B4-3464B760E3E3 + + className + IDECommandLineBuildLog + documentTypeString + <nil> + domainType + Xcode.IDEActivityLogDomainType.BuildLog + fileName + D705A532-F8F7-4B72-94B4-3464B760E3E3.xcactivitylog + hasPrimaryLog + + primaryObservable + + highLevelStatus + S + totalNumberOfAnalyzerIssues + 0 + totalNumberOfErrors + 0 + totalNumberOfTestFailures + 0 + totalNumberOfWarnings + 0 + + schemeIdentifier-containerName + WorkbenchReplacement project + schemeIdentifier-schemeName + WorkbenchReplacement + schemeIdentifier-sharedScheme + 1 + signature + Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug + timeStartedRecording + 813878073.32850504 + timeStoppedRecording + 813878087.86567605 + title + Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug + uniqueIdentifier + D705A532-F8F7-4B72-94B4-3464B760E3E3 + EB994874-BDA8-4BAC-BAF2-6EB6F20E2468 className diff --git a/build/ModuleCache.noindex/Session.modulevalidation b/build/ModuleCache.noindex/Session.modulevalidation index a8e1ffc..98b29e1 100644 --- a/build/ModuleCache.noindex/Session.modulevalidation +++ b/build/ModuleCache.noindex/Session.modulevalidation @@ -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") diff --git a/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache b/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache index d1d401a..5542a21 100644 Binary files a/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache and b/build/SDKStatCaches.noindex/macosx27.0-26A425-e829b21a6f8c9b70876ba0858053c0447231f6df4def33e022ae2eb8f782df1a.sdkstatcache differ diff --git a/build/info.plist b/build/info.plist index ba19501..7e44b95 100644 --- a/build/info.plist +++ b/build/info.plist @@ -3,7 +3,7 @@ LastAccessedDate - 2026-10-16T21:10:59Z + 2026-10-16T21:14:33Z WorkspacePath /Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj diff --git a/PHASE10-NOTES.md b/docs/PHASE10-NOTES.md similarity index 100% rename from PHASE10-NOTES.md rename to docs/PHASE10-NOTES.md diff --git a/PHASE11-NOTES.md b/docs/PHASE11-NOTES.md similarity index 100% rename from PHASE11-NOTES.md rename to docs/PHASE11-NOTES.md diff --git a/docs/PHASE12-NOTES.md b/docs/PHASE12-NOTES.md new file mode 100644 index 0000000..4abf3c2 --- /dev/null +++ b/docs/PHASE12-NOTES.md @@ -0,0 +1,10 @@ +# Phase 12 — Restore Validation + +- Exports now create `.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.