From 5948387b4e19619786a0081fd37c41a94810e38b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 16 Oct 2026 22:00:32 +0100 Subject: [PATCH] The second file contains a SHA-256 checksum of the SQL backup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows the application to detect whether the backup file has changed or become corrupted. 2. Verify Backup button The Import/Export window now includes Verify Backup…. Select an exported SQL file, and the application calculates its checksum and compares it with the accompanying checksum file. 3. Automatic verification before import When importing an SQL file, WorkbenchReplacement checks for an accompanying .sha256 file. If one exists, its checksum must match before SQL execution begins. Third-party SQL files without checksum files can still be imported. 4. Large-file handling Checksum calculation reads files in 1 MB blocks rather than loading the entire file into memory. This reduces memory usage when verifying large backups. --- WorkbenchReplacement/MainView.swift | 60 ++++++++++++++++++++++++++++- docs/PHASE4-NOTES.md | 15 ++++++++ docs/PHASE5-NOTES.md | 5 +++ docs/PHASE6-NOTES.md | 9 +++++ docs/PHASE7-NOTES.md | 10 +++++ 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 docs/PHASE4-NOTES.md create mode 100644 docs/PHASE5-NOTES.md create mode 100644 docs/PHASE6-NOTES.md create mode 100644 docs/PHASE7-NOTES.md diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index f3df0da..4eae89f 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -1681,7 +1681,7 @@ struct WorkbenchDatabaseTransferView: View { )) } } - Text("Triggers and routines require SHOW CREATE privileges. This exporter does not provide transaction-consistent snapshots or guaranteed row ordering.") + Text("Data export requires a non-null unique index on every selected table. Row counts are checked, but exports are not transaction-consistent: avoid writes during export.") .font(.caption).foregroundStyle(.secondary) } } @@ -2013,7 +2013,53 @@ struct WorkbenchDatabaseTransferView: View { } return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))" }.joined(separator: ", ") - let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET " + // Phase 7: require a non-null unique key for deterministic + // pagination. Without one, LIMIT/OFFSET can silently + // duplicate or omit rows even on an idle server. + let indexes = try await execute(""" + SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = \(sqlLiteral(database)) + AND TABLE_NAME = \(sqlLiteral(table)) + AND NON_UNIQUE = 0 + ORDER BY CASE WHEN INDEX_NAME = 'PRIMARY' THEN 0 ELSE 1 END, + INDEX_NAME, SEQ_IN_INDEX + """) + let nullable = try await execute(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = \(sqlLiteral(database)) + AND TABLE_NAME = \(sqlLiteral(table)) + AND IS_NULLABLE = 'YES' + """) + let nullableColumns = Set(nullable.rows.compactMap { $0.first }) + var grouped: [String: [String]] = [:] + var indexOrder: [String] = [] + for indexRow in indexes.rows where indexRow.count >= 2 { + let name = indexRow[0] + if grouped[name] == nil { + grouped[name] = [] + indexOrder.append(name) + } + grouped[name, default: []].append(indexRow[1]) + } + guard let indexName = indexOrder.first(where: { name in + guard let columns = grouped[name], !columns.isEmpty else { return false } + return columns.allSatisfy { !nullableColumns.contains($0) } + }), let keyColumns = grouped[indexName] else { + throw WorkbenchTransferError.message( + "Cannot safely export data for \(table): no non-null unique index. " + + "Add a primary key or export structure only." + ) + } + let orderBy = keyColumns.map(quoteIdentifier).joined(separator: ", ") + let beforeCount = try await execute("SELECT COUNT(*) FROM \(qualified)") + guard let expectedText = beforeCount.rows.first?.first, + let expectedCount = Int64(expectedText) else { + throw WorkbenchTransferError.message("Cannot count rows in \(table)") + } + try write("-- Export ordering: \(indexName) (\(orderBy)); expected rows: \(expectedCount)\n") + let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) ORDER BY \(orderBy) LIMIT 250 OFFSET " var offset = 0 while true { try Task.checkCancellation() @@ -2027,6 +2073,16 @@ struct WorkbenchDatabaseTransferView: View { status = "Exporting \(table): \(offset) rows" if batch.rows.count < 250 { break } } + let afterCount = try await execute("SELECT COUNT(*) FROM \(qualified)") + guard let afterText = afterCount.rows.first?.first, + let finalCount = Int64(afterText), + Int64(offset) == expectedCount, + finalCount == expectedCount else { + throw WorkbenchTransferError.message( + "Table \(table) changed during export or pagination was incomplete. " + + "The temporary backup has been discarded." + ) + } } progress = Double(index + 1) / Double(max(tables.count + views.count, 1)) } diff --git a/docs/PHASE4-NOTES.md b/docs/PHASE4-NOTES.md new file mode 100644 index 0000000..e1ef7eb --- /dev/null +++ b/docs/PHASE4-NOTES.md @@ -0,0 +1,15 @@ +# Phase 4 — SQL dump importer + +- Adds parsing for MySQL `DELIMITER` directives, including stored procedure/function/trigger bodies. +- Preserves MySQL executable comments (`/*! ... */`) and block comments. +- Handles quotes, escaped quotes, and semicolons inside strings. +- Updates the import interface explanation. +- Does not require mysql, mysqldump or Homebrew. + +## Important limitations + +This is not a production-ready backup/restore solution. The importer still reads the entire SQL file into memory, executes each statement through the existing connection runner, and does not roll back statements already executed after an error or cancellation. Server privileges and server-specific SQL syntax may prevent imports. Validate against a disposable database before using with valuable data. The macOS Xcode build has not been run in this environment. + +## Build + +`xcodebuild -project WorkbenchReplacement.xcodeproj -scheme WorkbenchReplacement -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath ./build build` diff --git a/docs/PHASE5-NOTES.md b/docs/PHASE5-NOTES.md new file mode 100644 index 0000000..d09fd08 --- /dev/null +++ b/docs/PHASE5-NOTES.md @@ -0,0 +1,5 @@ +# Phase 5 — Built-in MySQL routine and trigger export + +Adds opt-in export of stored procedures, functions and triggers via SHOW CREATE, using DELIMITER directives compatible with the Phase 4 importer. Export requires relevant MySQL privileges. The export includes these objects only when table structure export is enabled. The existing schema/table/view selection is unchanged. + +Limitations: not tested in Xcode or against a live MySQL instance; no consistent snapshot, deterministic pagination, or complete backup verification. Do not use as the only production backup. The importer executes individual statements and does not provide rollback on failure. diff --git a/docs/PHASE6-NOTES.md b/docs/PHASE6-NOTES.md new file mode 100644 index 0000000..d39b115 --- /dev/null +++ b/docs/PHASE6-NOTES.md @@ -0,0 +1,9 @@ +# Phase 6 — SQL backup integrity + +Adds streaming SHA-256 checksum creation for successfully exported SQL files, a **Verify Backup…** button, and automatic checksum validation before importing when a `.sha256` sidecar is present. + +The `.sha256` file must be kept alongside the `.sql` file with the same basename. The verifier reads the SQL file in 1 MiB blocks rather than loading the file into memory. Third-party SQL imports without a sidecar are allowed. + +**Not yet implemented:** transaction-consistent snapshots, reliable ordering of table rows across pages, import streaming, rollback on partial restore, cryptographic signing/authenticity, or end-to-end restoration tests. A matching SHA-256 proves file integrity relative to the stored checksum, not database completeness or correctness. Do not rely on this as a production backup system. + +This Linux environment cannot run Xcode; build and live-MySQL testing are required on macOS. diff --git a/docs/PHASE7-NOTES.md b/docs/PHASE7-NOTES.md new file mode 100644 index 0000000..204228e --- /dev/null +++ b/docs/PHASE7-NOTES.md @@ -0,0 +1,10 @@ +# Phase 7 — Safer table data exports + +- Requires a non-null unique index (prefers PRIMARY) for each table being exported with data. +- Adds explicit ORDER BY to every 250-row data page, preventing arbitrary server ordering. +- Checks row count before and after export, and compares it to rows written. On mismatch, aborts and deletes the temporary SQL file. +- Keeps Phase 6 SHA-256 sidecar verification and all existing UI features. + +## Limitations + +This is **not a consistent snapshot**. Concurrent updates can still change row contents without changing counts, and inserts/deletes can cancel each other out. OFFSET pagination is still used; a future phase must introduce a dedicated, isolated snapshot connection and keyset paging. Tables lacking a non-null unique index cannot be data-exported in this version. No Xcode or live server test was performed. Do not rely on these exports as sole production backups.