The second file contains a SHA-256 checksum of the SQL backup.

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.
This commit is contained in:
Mark Gregory
2026-10-16 22:06:06 +01:00
parent 5948387b4e
commit 0d0b46761f
23 changed files with 251 additions and 88 deletions
+17
View File
@@ -0,0 +1,17 @@
# Phase 8 — Dedicated InnoDB snapshot exports
- Uses an isolated native MySQL session for the entire export.
- Starts a REPEATABLE READ consistent, read-only transaction.
- Rejects non-InnoDB tables when exporting data.
- Keeps the same session across all paginated reads and row-count checks.
- Closes the dedicated session on cancellation/failure (rolling back its transaction).
- Preserves all Phase 7 export, import and integrity features.
## Limitations
This is NOT a full production backup guarantee: schema changes during export,
non-transactional objects, and offset pagination remain limitations. A stable
snapshot makes offset pagination consistent for InnoDB row data but not fast
for large offsets. Backup and restore validation against live MySQL is still
required. The MySQL wire protocol does not currently negotiate TLS, so do
not send credentials over untrusted networks.
+10
View File
@@ -0,0 +1,10 @@
# Phase 9 — Native streamed table export
- Removes the non-null unique index requirement (including LIBARC).
- Uses a single MySQL SELECT result stream per table, within the existing dedicated InnoDB consistent-snapshot session.
- Does not use LIMIT/OFFSET and does not buffer complete table results or impose the SQL editor's 100,000-row limit.
- Uses optional unique-key ORDER BY when available, but supports tables without a unique key.
- Counts streamed rows and compares against snapshot row count.
- Existing SHA-256 verification and temporary-file cleanup remain in place.
Limitations: The SQL exporter still formats rows using MySQL SQL expressions; very large individual values and packet sizes may be limited by server/client protocol settings. Non-InnoDB tables are still rejected for data exports. DDL changes during backup are not protected. Streaming cancellation may wait for network reads. Not Xcode-built or live-server tested.
+68 -37
View File
@@ -1681,7 +1681,7 @@ struct WorkbenchDatabaseTransferView: View {
))
}
}
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.")
Text("InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.")
.font(.caption).foregroundStyle(.secondary)
}
}
@@ -1871,6 +1871,11 @@ struct WorkbenchDatabaseTransferView: View {
showingImportConfirmation = true
}
// Export operations use a distinct authenticated session so their snapshot
// cannot be affected by SQL editor queries or administration refreshes.
@State private var exportSession: ConnectionProfile?
@State private var exportPassword = ""
@MainActor
private func execute(_ statement: String) async throws -> QueryResult {
guard let profile = selectedProfile else {
@@ -1880,9 +1885,9 @@ struct WorkbenchDatabaseTransferView: View {
}
return try await NativeMySQLRunner.run(
profile: profile,
profile: exportSession ?? profile,
database: statement == "SHOW DATABASES" ? "" : database,
password: PasswordStore.read(profile.id),
password: exportSession == nil ? PasswordStore.read(profile.id) : exportPassword,
sql: statement
)
}
@@ -1958,6 +1963,16 @@ struct WorkbenchDatabaseTransferView: View {
? availableViews.filter { selectedViews.contains($0) }
: []
guard !selectedTables.isEmpty || !views.isEmpty || (exportStructure && (includeTriggers || includeRoutines)) else { return }
guard let activeProfile = selectedProfile else {
status = "Export failed: No connection selected"
return
}
// Clone the connection profile with a fresh identity; the session pool
// then creates a separate socket, independent of the editor session.
var dedicatedProfile = activeProfile
dedicatedProfile.id = UUID()
exportPassword = PasswordStore.read(activeProfile.id)
exportSession = dedicatedProfile
isWorking = true
progress = 0
let tables = availableTables.filter { selectedTables.contains($0) }
@@ -1966,10 +1981,31 @@ struct WorkbenchDatabaseTransferView: View {
defer {
try? handle?.close()
try? FileManager.default.removeItem(at: tempURL)
NativeMySQLRunner.disconnect(profileID: dedicatedProfile.id)
exportSession = nil
exportPassword = ""
isWorking = false
operationTask = nil
}
do {
// A consistent InnoDB snapshot requires every SELECT to execute on
// this SAME connection. MyISAM/MEMORY cannot participate.
for table in tables where exportData {
let engine = try await execute("""
SELECT ENGINE FROM information_schema.TABLES
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
AND TABLE_NAME = \(sqlLiteral(table))
AND TABLE_TYPE = 'BASE TABLE'
""")
guard let name = engine.rows.first?.first,
name.uppercased() == "INNODB" else {
throw WorkbenchTransferError.message(
"Consistent data export requires InnoDB. Table \(table) uses \(engine.rows.first?.first ?? "an unknown engine"). Export its structure only."
)
}
}
_ = try await execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
_ = try await execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY")
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
throw WorkbenchTransferError.message("Cannot create temporary export file")
}
@@ -2013,9 +2049,9 @@ struct WorkbenchDatabaseTransferView: View {
}
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
}.joined(separator: ", ")
// 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.
// A single streamed SELECT keeps every row in the same
// InnoDB read view and works without a primary/unique key.
// No OFFSET, no 100,000-row cap, no full result buffering.
let indexes = try await execute("""
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
FROM information_schema.STATISTICS
@@ -2043,46 +2079,39 @@ struct WorkbenchDatabaseTransferView: View {
}
grouped[name, default: []].append(indexRow[1])
}
guard let indexName = indexOrder.first(where: { name in
let usableIndex = indexOrder.first { 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 {
let orderBy = usableIndex.flatMap { grouped[$0] }?
.map(quoteIdentifier).joined(separator: ", ")
let countResult = try await execute("SELECT COUNT(*) FROM \(qualified)")
guard let countText = countResult.rows.first?.first,
let expectedCount = Int64(countText) 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()
let batch = try await execute(statement + String(offset))
if batch.rows.isEmpty { break }
for row in batch.rows {
guard let literalRow = row.first else { continue }
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
}
offset += batch.rows.count
status = "Exporting \(table): \(offset) rows"
if batch.rows.count < 250 { break }
try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n")
let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" +
(orderBy.map { " ORDER BY " + $0 } ?? "")
let outputHandle = handle!
let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8)
let suffix = Data(";\n".utf8)
let written = try await NativeMySQLRunner.stream(
profile: dedicatedProfile,
database: database,
password: exportPassword,
sql: select
) { literal in
try outputHandle.write(contentsOf: prefix)
try outputHandle.write(contentsOf: Data(literal.utf8))
try outputHandle.write(contentsOf: suffix)
}
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 {
guard written == expectedCount else {
throw WorkbenchTransferError.message(
"Table \(table) changed during export or pagination was incomplete. " +
"The temporary backup has been discarded."
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
)
}
status = "Exported \(table): \(written) rows"
}
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
}
@@ -2156,6 +2185,8 @@ struct WorkbenchDatabaseTransferView: View {
}
}
try Task.checkCancellation()
// Release the read view before finalizing the export file.
_ = try await execute("COMMIT")
try handle?.close()
handle = nil
if FileManager.default.fileExists(atPath: url.path) {
+60
View File
@@ -287,6 +287,35 @@ final class MySQLWireConnection {
private var hostIsLoopback = false
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
// Streams result rows without the query editor's 100,000-row limit.
// The callback is invoked on the session's serial background queue.
func streamFirstColumn(_ sql: String, onRow: (String) throws -> Void) throws -> Int64 {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
guard first.first != 0x00 else { return 0 }
var cursor = ByteCursor(bytes: first)
guard let columnCount = try cursor.lenInt(), columnCount == 1 else {
throw MySQLWireError(message: "Export query must return exactly one column")
}
_ = try readPacket() // Column metadata
_ = try readPacket() // Column EOF
var count: Int64 = 0
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
guard let value = try row.lenString() else {
throw MySQLWireError(message: "Unexpected NULL export row")
}
try onRow(value)
count += 1
}
return count
}
func query(_ sql: String) throws -> QueryResult {
sequence = 0
try send([0x03] + Array(sql.utf8))
@@ -367,6 +396,23 @@ private final class PersistentMySQLSessions {
}
}
func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: (String) throws -> Void) throws -> Int64 {
try queue.sync {
guard let entry = connections[profile.id],
entry.profile == profile, entry.password == password else {
throw MySQLWireError(message: "Export session is no longer connected")
}
do {
return try entry.connection.streamFirstColumn(sql, onRow: onRow)
} catch {
connections.removeValue(forKey: profile.id)
throw error
}
}
}
func disconnect(profileID: UUID) {
queue.sync { _ = connections.removeValue(forKey: profileID) }
}
@@ -382,6 +428,20 @@ enum NativeMySQLRunner {
}.value
}
static func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: @escaping (String) throws -> Void) async throws -> Int64 {
try await Task.detached(priority: .userInitiated) {
try PersistentMySQLSessions.shared.stream(
profile: profile, database: database, password: password,
sql: sql, onRow: { row in
try Task<Never, Never>.checkCancellation()
try onRow(row)
}
)
}.value
}
static func disconnect(profileID: UUID) {
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
}
@@ -428,7 +428,7 @@
},
{
"comment": "",
"key": "Triggers and routines require SHOW CREATE privileges. This exporter does not provide transaction-consistent snapshots or guaranteed row ordering.",
"key": "InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.",
"location": {
"startingColumn": 30,
"startingLine": 1684
@@ -12,3 +12,6 @@
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
/Volumes/Developer/Applications/WorkbenchReplacement/build/Build/Products/Debug/WorkbenchReplacement.app
@@ -19,7 +19,7 @@
<dict>
<key>cdhash</key>
<data>
RB/YriyQlwTsASbrsirLbaoN96E=
lD7vQzFdLMorMrk09F1ArmxtEu4=
</data>
<key>requirement</key>
<string>identifier "WorkbenchReplacement.debug" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: Mark Gregory (FLBRJ3BYDD)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */</string>
+89 -47
View File
@@ -48,7 +48,7 @@
<key>uniqueIdentifier</key>
<string>32E9EC2D-B9B8-4CD2-8B3D-CD693A4CBD97</string>
</dict>
<key>3389C95F-527F-43D4-BFD7-803CFE84AD6A</key>
<key>68955E3A-0B2E-4614-AC89-290DCD28B6D0</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
@@ -57,7 +57,7 @@
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>3389C95F-527F-43D4-BFD7-803CFE84AD6A.xcactivitylog</string>
<string>68955E3A-0B2E-4614-AC89-290DCD28B6D0.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
@@ -82,55 +82,13 @@
<key>signature</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>timeStartedRecording</key>
<real>813876842.83179998</real>
<real>813877338.21300805</real>
<key>timeStoppedRecording</key>
<real>813876855.72915101</real>
<real>813877351.33591294</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>3389C95F-527F-43D4-BFD7-803CFE84AD6A</string>
</dict>
<key>705B952B-2A37-40D9-B731-8E2290C15462</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>705B952B-2A37-40D9-B731-8E2290C15462.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>813876591.82079995</real>
<key>timeStoppedRecording</key>
<real>813876604.10285401</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>705B952B-2A37-40D9-B731-8E2290C15462</string>
<string>68955E3A-0B2E-4614-AC89-290DCD28B6D0</string>
</dict>
<key>921D995A-6441-4831-9E4E-3283B2F38B89</key>
<dict>
@@ -174,6 +132,90 @@
<key>uniqueIdentifier</key>
<string>921D995A-6441-4831-9E4E-3283B2F38B89</string>
</dict>
<key>EB994874-BDA8-4BAC-BAF2-6EB6F20E2468</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>EB994874-BDA8-4BAC-BAF2-6EB6F20E2468.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>813877513.105088</real>
<key>timeStoppedRecording</key>
<real>813877526.28998804</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>EB994874-BDA8-4BAC-BAF2-6EB6F20E2468</string>
</dict>
<key>EDDF2132-184C-41A2-A915-0EC31843BAD5</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>EDDF2132-184C-41A2-A915-0EC31843BAD5.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>813877252.80588198</real>
<key>timeStoppedRecording</key>
<real>813877266.32472801</real>
<key>title</key>
<string>Building project WorkbenchReplacement with scheme WorkbenchReplacement and configuration Debug</string>
<key>uniqueIdentifier</key>
<string>EDDF2132-184C-41A2-A915-0EC31843BAD5</string>
</dict>
</dict>
</dict>
</plist>
@@ -1 +1 @@
1792184268.211045: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/WorkbenchReplacement/build/ModuleCache.noindex")
1792184713.510244: 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-16T20:57:48Z</date>
<date>2026-10-16T21:05:13Z</date>
<key>WorkspacePath</key>
<string>/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj</string>
</dict>