From 6c5395314a73574436ffe4b4dbe21faa27a2a9b8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Sep 2026 20:16:46 +0700 Subject: [PATCH] fix(welcome): remove the sample database journal files on reset --- CHANGELOG.md | 1 + .../Database/RemoteDatabaseFileTransfer.swift | 62 +++-- .../SampleDatabaseService.swift | 31 ++- .../Connection/DatabaseFileLayout.swift | 7 +- .../RemoteDatabaseFileCorrectnessTests.swift | 83 ++++++- .../Database/RemoteDatabaseFileTests.swift | 6 + .../Services/SampleDatabaseServiceTests.swift | 230 +++++++++++++++++- 7 files changed, 381 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8497d275..7b227ec1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Two rename fields when renaming a favorite on iPhone and iPad. - Connection names cut off at large text sizes on iPhone and iPad. - `Cmd+W` closing the whole connection instead of the current tab until something in the window was clicked. +- **Reset Sample Database** leaving the old Chinook copy's journal files beside the fresh one. ### Security diff --git a/TablePro/Core/Database/RemoteDatabaseFileTransfer.swift b/TablePro/Core/Database/RemoteDatabaseFileTransfer.swift index 90981dd4bd..dfa3393b03 100644 --- a/TablePro/Core/Database/RemoteDatabaseFileTransfer.swift +++ b/TablePro/Core/Database/RemoteDatabaseFileTransfer.swift @@ -58,6 +58,20 @@ struct RemoteFetchResult: Sendable { let plan: RemoteFetchPlan } +internal protocol RemoteFileSource { + func exists(_ path: String) -> Bool + + @discardableResult + func download( + remotePath: String, + to localURL: URL, + progress: (@Sendable (UInt64, UInt64) -> Void)?, + isCancelled: @escaping @Sendable () -> Bool + ) throws -> (bytes: UInt64, sha256: String) +} + +extension LibSSH2SFTPSession: RemoteFileSource {} + /// Copies a database file from a server into a local working copy. /// /// Every rule here comes from something that was measured rather than assumed. The three that @@ -156,6 +170,7 @@ enum RemoteDatabaseFileTransfer { try? FileManager.default.removeItem(at: staging) let downloaded: (bytes: UInt64, sha256: String) + let fetchedSidecars: Set switch plan { case .remoteSnapshot(let executable): downloaded = try fetchViaRemoteSnapshot( @@ -166,12 +181,13 @@ enum RemoteDatabaseFileTransfer { progress: progress, isCancelled: isCancelled ) + fetchedSidecars = [] case .directCopy(let sidecars): downloaded = try session.download( remotePath: remotePath, to: staging, progress: progress, isCancelled: isCancelled ) - try fetchSidecars( - session: session, + fetchedSidecars = try fetchSidecars( + from: session, remotePath: remotePath, sidecars: sidecars, destinationDirectory: destinationDirectory, @@ -195,7 +211,7 @@ enum RemoteDatabaseFileTransfer { try replaceLocalItem(at: workingCopy, with: staging) clearStaleSidecars( layout: layout, - plan: plan, + keeping: fetchedSidecars, destinationDirectory: destinationDirectory, fileName: fileName ) @@ -224,25 +240,21 @@ enum RemoteDatabaseFileTransfer { return RemoteFetchResult(workingCopy: workingCopy, manifest: manifest, plan: plan) } - /// A reader that opens a working copy must not find a `-wal` or `-shm` left over from a previous - /// copy of a different file, because SQLite would replay it against bytes it no longer matches. + /// A reader that opens a working copy must not find a `-journal`, `-wal` or `-shm` left over from + /// a previous copy of a different file, because SQLite would roll it back or replay it against + /// bytes it no longer matches. /// - /// A snapshot is fully checkpointed and carries no log, so every stale sidecar goes. A direct - /// copy keeps the ones it just fetched (the server had them) and clears the rest, which is what - /// removes a `-wal` that the server has since checkpointed away. + /// Only a sidecar this fetch downloaded is kept, never one its plan merely listed. A snapshot is + /// fully checkpointed and downloads none. A `-wal` the server checkpoints away, or a `-journal` + /// whose transaction ends, between planning and fetching is never downloaded, and the local file + /// of that name still belongs to the previous copy. static func clearStaleSidecars( layout: DatabaseFileLayout, - plan: RemoteFetchPlan, + keeping fetchedSidecars: Set, destinationDirectory: URL, fileName: String ) { - let kept: Set - if case .directCopy(let sidecars) = plan { - kept = Set(sidecars) - } else { - kept = [] - } - for suffix in layout.staleAfterReplaceSuffixes where !kept.contains(suffix) { + for suffix in layout.staleAfterReplaceSuffixes where !fetchedSidecars.contains(suffix) { try? FileManager.default.removeItem( at: destinationDirectory.appendingPathComponent(fileName + suffix) ) @@ -286,22 +298,28 @@ enum RemoteDatabaseFileTransfer { ) } - private static func fetchSidecars( - session: LibSSH2SFTPSession, + static func fetchSidecars( + from source: some RemoteFileSource, remotePath: String, sidecars: [String], destinationDirectory: URL, fileName: String, isCancelled: @escaping @Sendable () -> Bool - ) throws { + ) throws -> Set { + var fetched: Set = [] for suffix in sidecars { if isCancelled() { throw SFTPError.cancelled } - let source = remotePath + suffix - guard session.exists(source) else { continue } + let remoteSidecar = remotePath + suffix + guard source.exists(remoteSidecar) else { + Self.logger.info("The \(suffix, privacy: .public) sidecar was gone before it was fetched") + continue + } let target = destinationDirectory.appendingPathComponent(fileName + suffix) - try session.download(remotePath: source, to: target, isCancelled: isCancelled) + try source.download(remotePath: remoteSidecar, to: target, progress: nil, isCancelled: isCancelled) + fetched.insert(suffix) Self.logger.info("Fetched the \(suffix, privacy: .public) sidecar") } + return fetched } // MARK: - Helpers diff --git a/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift b/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift index 95cabc3712..2dd852dd6d 100644 --- a/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift +++ b/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift @@ -35,6 +35,7 @@ internal final class SampleDatabaseService { ) nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "SampleDatabaseService") + private static let installedFileName = "Chinook.sqlite" private let bundledFileResolver: () -> URL? private let fileManager: FileManager @@ -58,7 +59,14 @@ internal final class SampleDatabaseService { } internal var installedFileURL: URL { - baseDirectoryProvider().appendingPathComponent("Chinook.sqlite", isDirectory: false) + baseDirectoryProvider().appendingPathComponent(Self.installedFileName, isDirectory: false) + } + + private var installedSidecarURLs: [URL] { + let directory = baseDirectoryProvider() + return DatabaseFileLayout.sqliteFamily.staleAfterReplaceSuffixes.map { suffix in + directory.appendingPathComponent(Self.installedFileName + suffix, isDirectory: false) + } } internal func installIfNeeded() throws { @@ -80,6 +88,8 @@ internal final class SampleDatabaseService { return } + try removeInstalledDatabaseFiles() + do { try fileManager.copyItem(at: bundled, to: installed) Self.logger.info("Installed sample database to \(installed.path, privacy: .private(mask: .hash))") @@ -105,13 +115,7 @@ internal final class SampleDatabaseService { throw SampleDatabaseError.copyFailed(message: error.localizedDescription) } - if fileManager.fileExists(atPath: installed.path) { - do { - try fileManager.removeItem(at: installed) - } catch { - throw SampleDatabaseError.copyFailed(message: error.localizedDescription) - } - } + try removeInstalledDatabaseFiles() do { try fileManager.copyItem(at: bundled, to: installed) @@ -121,6 +125,17 @@ internal final class SampleDatabaseService { } } + private func removeInstalledDatabaseFiles() throws { + for url in [installedFileURL] + installedSidecarURLs where fileManager.fileExists(atPath: url.path) { + do { + try fileManager.removeItem(at: url) + } catch { + throw SampleDatabaseError.copyFailed(message: error.localizedDescription) + } + Self.logger.info("Removed sample database file \(url.lastPathComponent, privacy: .public)") + } + } + internal func isSampleConnection(_ connection: DatabaseConnection) -> Bool { if connection.isSample { return true } guard connection.type == .sqlite else { return false } diff --git a/TablePro/Models/Connection/DatabaseFileLayout.swift b/TablePro/Models/Connection/DatabaseFileLayout.swift index 79ae66c815..c29f0c0f4a 100644 --- a/TablePro/Models/Connection/DatabaseFileLayout.swift +++ b/TablePro/Models/Connection/DatabaseFileLayout.swift @@ -34,11 +34,12 @@ enum DatabaseFileLayout: Sendable, Equatable { } } - /// Suffixes that are rebuilt from the main file and must be cleared after it is replaced, so a - /// reader cannot apply a log that belongs to the file that used to be there. + /// Suffixes of every file the engine keeps beside a database that must not outlive a replaced + /// main file, so a reader cannot roll back or replay a journal that belongs to the file that + /// used to be there. var staleAfterReplaceSuffixes: [String] { switch self { - case .sqliteFamily: return ["-wal", "-shm"] + case .sqliteFamily: return ["-journal", "-wal", "-shm"] case .duckdb: return [".wal"] case .plainText: return [] } diff --git a/TableProTests/Core/Database/RemoteDatabaseFileCorrectnessTests.swift b/TableProTests/Core/Database/RemoteDatabaseFileCorrectnessTests.swift index 301c2d3b1d..a2fe1420f8 100644 --- a/TableProTests/Core/Database/RemoteDatabaseFileCorrectnessTests.swift +++ b/TableProTests/Core/Database/RemoteDatabaseFileCorrectnessTests.swift @@ -55,20 +55,39 @@ struct RemoteDatabaseFileCorrectnessTests { // MARK: - Stale sidecar clearing - @Test("A snapshot fetch clears a stale write-ahead log and shared-memory index") - func snapshotClearsStaleSidecars() throws { + @Test("A fetch that downloaded no sidecar clears a stale rollback journal, write-ahead log and shared-memory index") + func fetchWithoutSidecarsClearsEveryStaleSidecar() throws { let directory = try temporaryDirectory() let fileName = "app.db" - for suffix in ["", "-wal", "-shm"] { + for suffix in ["", "-journal", "-wal", "-shm"] { try Data("x".utf8).write(to: directory.appendingPathComponent(fileName + suffix)) } RemoteDatabaseFileTransfer.clearStaleSidecars( layout: .sqliteFamily, - plan: .remoteSnapshot(executable: "sqlite3"), + keeping: [], destinationDirectory: directory, fileName: fileName ) #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName).path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-journal").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-wal").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-shm").path)) + } + + @Test("A direct copy keeps a rollback journal it fetched") + func directCopyKeepsFetchedJournal() throws { + let directory = try temporaryDirectory() + let fileName = "app.db" + for suffix in ["", "-journal", "-wal", "-shm"] { + try Data("x".utf8).write(to: directory.appendingPathComponent(fileName + suffix)) + } + RemoteDatabaseFileTransfer.clearStaleSidecars( + layout: .sqliteFamily, + keeping: ["-journal"], + destinationDirectory: directory, + fileName: fileName + ) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-journal").path)) #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-wal").path)) #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-shm").path)) } @@ -82,7 +101,7 @@ struct RemoteDatabaseFileCorrectnessTests { } RemoteDatabaseFileTransfer.clearStaleSidecars( layout: .sqliteFamily, - plan: .directCopy(sidecars: ["-wal"]), + keeping: ["-wal"], destinationDirectory: directory, fileName: fileName ) @@ -90,6 +109,39 @@ struct RemoteDatabaseFileCorrectnessTests { #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-shm").path)) } + @Test("A direct copy clears a stale rollback journal its plan listed but the server had dropped by fetch time") + func directCopyClearsAJournalTheServerDroppedAfterPlanning() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let fileName = "app.db" + for suffix in ["", "-journal", "-wal", "-shm"] { + try Data("stale".utf8).write(to: directory.appendingPathComponent(fileName + suffix)) + } + let freshLog = Data("fresh log".utf8) + let server = StubRemoteFileSource(files: ["/srv/app.db-wal": freshLog]) + + let fetched = try RemoteDatabaseFileTransfer.fetchSidecars( + from: server, + remotePath: "/srv/app.db", + sidecars: ["-wal", "-journal"], + destinationDirectory: directory, + fileName: fileName, + isCancelled: { false } + ) + RemoteDatabaseFileTransfer.clearStaleSidecars( + layout: .sqliteFamily, + keeping: fetched, + destinationDirectory: directory, + fileName: fileName + ) + + #expect(fetched == ["-wal"]) + let log = try Data(contentsOf: directory.appendingPathComponent(fileName + "-wal")) + #expect(log == freshLog) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-journal").path)) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName + "-shm").path)) + } + // MARK: - Killed remote command @Test("A signal-killed remote command does not report success") @@ -123,3 +175,24 @@ struct RemoteDatabaseFileCorrectnessTests { #expect(!FileManager.default.fileExists(atPath: stale.path)) } } + +private struct StubRemoteFileSource: RemoteFileSource { + let files: [String: Data] + + func exists(_ path: String) -> Bool { + files[path] != nil + } + + func download( + remotePath: String, + to localURL: URL, + progress: (@Sendable (UInt64, UInt64) -> Void)?, + isCancelled: @escaping @Sendable () -> Bool + ) throws -> (bytes: UInt64, sha256: String) { + guard let data = files[remotePath] else { + throw SFTPError.noSuchFile(path: remotePath) + } + try data.write(to: localURL) + return (bytes: UInt64(data.count), sha256: "") + } +} diff --git a/TableProTests/Core/Database/RemoteDatabaseFileTests.swift b/TableProTests/Core/Database/RemoteDatabaseFileTests.swift index 4f498b5084..6c45a07f8a 100644 --- a/TableProTests/Core/Database/RemoteDatabaseFileTests.swift +++ b/TableProTests/Core/Database/RemoteDatabaseFileTests.swift @@ -86,6 +86,12 @@ struct RemoteDatabaseFileTests { #expect(!suffixes.contains("-shm")) } + @Test("Replacing a SQLite file clears its rollback journal, write-ahead log and shared-memory index") + func sqliteStaleSidecarsIncludeTheRollbackJournal() { + let suffixes = Set(DatabaseFileLayout.sqliteFamily.staleAfterReplaceSuffixes) + #expect(suffixes == ["-journal", "-wal", "-shm"]) + } + /// DuckDB writes `app.duckdb.wal`, with a dot. Taking SQLite's hyphen to it fetches nothing and /// leaves the real log behind to be replayed against a file it no longer matches. @Test("DuckDB's log is named with a dot, and SQLite's suffixes never reach it") diff --git a/TableProTests/Services/SampleDatabaseServiceTests.swift b/TableProTests/Services/SampleDatabaseServiceTests.swift index 768bd6dd4a..5f130c2611 100644 --- a/TableProTests/Services/SampleDatabaseServiceTests.swift +++ b/TableProTests/Services/SampleDatabaseServiceTests.swift @@ -4,6 +4,7 @@ // import Foundation +import SQLite3 import TableProPluginKit import Testing @@ -25,6 +26,23 @@ struct SampleDatabaseServiceTests { let installedURL: URL let inspector: StubInspector let workingDirectory: URL + + func sidecarURL(_ suffix: String) -> URL { + SampleSQLite.sidecarURL(of: installedURL, suffix: suffix) + } + + func existingSidecarSuffixes() -> [String] { + SampleSQLite.sidecarSuffixes.filter { suffix in + FileManager.default.fileExists(atPath: sidecarURL(suffix).path) + } + } + } + + private func makeDatabaseHarness() throws -> Harness { + let harness = try makeHarness() + try FileManager.default.removeItem(at: harness.bundledURL) + try SampleSQLite.writeSampleDatabase(at: harness.bundledURL) + return harness } private func makeHarness(skipBundleFile: Bool = false) throws -> Harness { @@ -107,17 +125,110 @@ struct SampleDatabaseServiceTests { #expect(installedData == Self.bundledMarker) } - @Test("resetToBundled throws connectionInUse when the sample is open") + @Test("resetToBundled throws connectionInUse when the sample is open and leaves its files alone") func resetToBundled_throwsConnectionInUse_whenConnectionOpen() throws { let harness = try makeHarness() defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } try harness.service.installIfNeeded() + let edited = Data("USER-EDITED".utf8) + try edited.write(to: harness.installedURL) + for suffix in SampleSQLite.sidecarSuffixes { + try Data("LIVE\(suffix)".utf8).write(to: harness.sidecarURL(suffix)) + } harness.inspector.sampleConnectionOpen = true #expect(throws: SampleDatabaseError.connectionInUse) { try harness.service.resetToBundled() } + + let installedData = try Data(contentsOf: harness.installedURL) + #expect(installedData == edited) + for suffix in SampleSQLite.sidecarSuffixes { + let sidecarData = try Data(contentsOf: harness.sidecarURL(suffix)) + #expect(sidecarData == Data("LIVE\(suffix)".utf8), "\(suffix) must survive a refused reset") + } + } + + @Test("resetToBundled removes the rollback journal, write-ahead log and shared-memory index") + func resetToBundled_removesEverySidecar() throws { + let harness = try makeHarness() + defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } + + try harness.service.installIfNeeded() + for suffix in SampleSQLite.sidecarSuffixes { + try Data("STALE\(suffix)".utf8).write(to: harness.sidecarURL(suffix)) + } + + try harness.service.resetToBundled() + + #expect(harness.existingSidecarSuffixes().isEmpty) + let installedData = try Data(contentsOf: harness.installedURL) + #expect(installedData == Self.bundledMarker) + } + + @Test("resetToBundled leaves no write-ahead log to replay over the fresh copy") + func resetToBundled_leavesNoWriteAheadLogToReplay() throws { + let harness = try makeDatabaseHarness() + defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } + + try harness.service.installIfNeeded() + try SampleSQLite.stageCrashedWriteAheadLog(beside: harness.installedURL) + let writeAheadLog = try Data(contentsOf: harness.sidecarURL("-wal")) + try #require(writeAheadLog.count > SampleSQLite.writeAheadLogHeaderSize) + + try harness.service.resetToBundled() + + try expectPristineSample(harness) + } + + @Test("resetToBundled leaves no hot journal to roll back into the fresh copy") + func resetToBundled_leavesNoHotJournalToRollBack() throws { + let harness = try makeDatabaseHarness() + defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } + + try harness.service.installIfNeeded() + try SampleSQLite.stageHotJournal(beside: harness.installedURL) + let journal = try Data(contentsOf: harness.sidecarURL("-journal")) + try #require(journal.starts(with: SampleSQLite.rollbackJournalMagic)) + + try harness.service.resetToBundled() + + try expectPristineSample(harness) + } + + @Test("installIfNeeded clears journal files a missing database left behind") + func installIfNeeded_clearsJournalFilesOfMissingDatabase() throws { + let harness = try makeDatabaseHarness() + defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } + + try harness.service.installIfNeeded() + try SampleSQLite.stageHotJournal(beside: harness.installedURL) + let journal = try Data(contentsOf: harness.sidecarURL("-journal")) + try #require(journal.starts(with: SampleSQLite.rollbackJournalMagic)) + try FileManager.default.removeItem(at: harness.installedURL) + + try harness.service.installIfNeeded() + + try expectPristineSample(harness) + } + + @Test("installIfNeeded never touches the journal files of an installed database") + func installIfNeeded_keepsJournalFilesOfInstalledDatabase() throws { + let harness = try makeHarness() + defer { try? FileManager.default.removeItem(at: harness.workingDirectory) } + + try harness.service.installIfNeeded() + let live = Data("LIVE".utf8) + try live.write(to: harness.sidecarURL("-journal")) + try live.write(to: harness.sidecarURL("-wal")) + + try harness.service.installIfNeeded() + + let journal = try Data(contentsOf: harness.sidecarURL("-journal")) + let writeAheadLog = try Data(contentsOf: harness.sidecarURL("-wal")) + #expect(journal == live) + #expect(writeAheadLog == live) } @Test("installIfNeeded throws bundleMissing when the bundle has no Chinook file") @@ -129,4 +240,121 @@ struct SampleDatabaseServiceTests { try harness.service.installIfNeeded() } } + + private func expectPristineSample(_ harness: Harness) throws { + #expect(harness.existingSidecarSuffixes().isEmpty) + + let report = try SampleSQLite.report(at: harness.installedURL) + #expect(report.originalRows == SampleSQLite.rowCount) + #expect(report.integrity == "ok") + + let installedData = try Data(contentsOf: harness.installedURL) + let bundledData = try Data(contentsOf: harness.bundledURL) + #expect(installedData == bundledData) + } +} + +private enum SampleSQLite { + static let sidecarSuffixes = ["-journal", "-wal", "-shm"] + static let rowCount = 3_000 + static let writeAheadLogHeaderSize = 32 + static let rollbackJournalMagic: [UInt8] = [0xD9, 0xD5, 0x05, 0xF9, 0x20, 0xA1, 0x63, 0xD7] + + struct Failure: Error, CustomStringConvertible { + let description: String + } + + struct Report { + let originalRows: Int + let integrity: String + } + + static func sidecarURL(of databaseURL: URL, suffix: String) -> URL { + URL(fileURLWithPath: databaseURL.path + suffix) + } + + static func writeSampleDatabase(at url: URL) throws { + try withDatabase(at: url) { database in + try exec(database, "CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)") + try exec( + database, + """ + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < \(rowCount)) + INSERT INTO t SELECT i, 'original' FROM n + """ + ) + } + } + + static func report(at url: URL) throws -> Report { + try withDatabase(at: url) { database in + let count = try firstValue(database, "SELECT count(*) FROM t WHERE v = 'original'") + guard let originalRows = Int(count) else { + throw Failure(description: "count(*) returned \(count)") + } + let integrity = try firstValue(database, "PRAGMA integrity_check") + return Report(originalRows: originalRows, integrity: integrity) + } + } + + static func stageCrashedWriteAheadLog(beside url: URL) throws { + let writeAheadLogURL = sidecarURL(of: url, suffix: "-wal") + let writeAheadLog = try withDatabase(at: url) { database in + try exec(database, "PRAGMA journal_mode = WAL") + try exec(database, "UPDATE t SET v = 'edited' WHERE id <= 500") + return try Data(contentsOf: writeAheadLogURL) + } + try writeAheadLog.write(to: writeAheadLogURL) + } + + static func stageHotJournal(beside url: URL) throws { + let journalURL = sidecarURL(of: url, suffix: "-journal") + let journal = try withDatabase(at: url) { database in + try exec(database, "UPDATE t SET v = 'edited' WHERE id <= 1500") + try exec(database, "BEGIN") + try exec(database, "UPDATE t SET v = 'uncommitted'") + let flushStatus = sqlite3_db_cacheflush(database) + guard flushStatus == SQLITE_OK else { + throw Failure(description: "sqlite3_db_cacheflush returned \(flushStatus)") + } + let journal = try Data(contentsOf: journalURL) + try exec(database, "ROLLBACK") + return journal + } + try journal.write(to: journalURL) + } + + private static func withDatabase( + at url: URL, + _ body: (OpaquePointer) throws -> Result + ) throws -> Result { + var handle: OpaquePointer? + let status = sqlite3_open_v2(url.path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) + defer { sqlite3_close(handle) } + guard status == SQLITE_OK, let handle else { + throw Failure(description: "sqlite3_open_v2 returned \(status)") + } + return try body(handle) + } + + private static func exec(_ database: OpaquePointer, _ sql: String) throws { + var message: UnsafeMutablePointer? + let status = sqlite3_exec(database, sql, nil, nil, &message) + defer { sqlite3_free(message) } + guard status == SQLITE_OK else { + throw Failure(description: message.map { String(cString: $0) } ?? "sqlite3_exec returned \(status)") + } + } + + private static func firstValue(_ database: OpaquePointer, _ sql: String) throws -> String { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { + throw Failure(description: String(cString: sqlite3_errmsg(database))) + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, let text = sqlite3_column_text(statement, 0) else { + throw Failure(description: String(cString: sqlite3_errmsg(database))) + } + return String(cString: text) + } }