From 17f2789ace0155afb0f52533310356c9690e9f0e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 6 Nov 2025 11:25:44 +0200 Subject: [PATCH] Add ability to delete SQLite files --- CHANGELOG.md | 10 +++ .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 62 +++++++++++++- .../Protocol/PowerSyncDatabaseProtocol.swift | 19 ++++- .../KotlinPowerSyncDatabaseImplTests.swift | 81 ++++++++++++++++++- 4 files changed, 163 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a8370..3a3d7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,17 @@ ## 1.6.1 (unreleased) * Update Kotlin SDK to 1.7.0. +* Add `close(deleteDatabase:)` method to `PowerSyncDatabaseProtocol` for deleting SQLite database files when closing the database. This includes the main database file and all WAL mode files (.wal, .shm, .journal). Files that don't exist are ignored, but an error is thrown if a file exists but cannot be deleted. +```swift +// Close the database and delete all SQLite files +try await database.close(deleteDatabase: true) + +// Close the database without deleting files (default behavior) +try await database.close(deleteDatabase: false) +// or simply +try await database.close() +``` ## 1.6.0 * Update core extension to 0.4.6 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.6)) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 10bbd97..aa81d1d 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -9,6 +9,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase private let encoder = JSONEncoder() let currentStatus: SyncStatus + private let dbFilename: String init( schema: Schema, @@ -23,6 +24,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, logger: logger.kLogger ) self.logger = logger + self.dbFilename = dbFilename currentStatus = KotlinSyncStatus( baseStatus: kotlinDatabase.currentStatus ) @@ -74,7 +76,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, batch: base ) } - + func getCrudTransactions() -> any CrudTransactions { return KotlinCrudTransactions(db: kotlinDatabase) } @@ -323,6 +325,21 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol, try await kotlinDatabase.close() } + func close(deleteDatabase: Bool = false) async throws { + // Close the SQLite connections + try await close() + + if deleteDatabase { + try await self.deleteDatabase() + } + } + + private func deleteDatabase() async throws { + // We can use the supplied dbLocation when we support that in future + let directory = try appleDefaultDatabaseDirectory() + try deleteSQLiteFiles(dbFilename: dbFilename, in: directory) + } + /// Tries to convert Kotlin PowerSyncExceptions to Swift Exceptions private func wrapPowerSyncException( handler: () async throws -> R) @@ -449,3 +466,46 @@ func wrapTransactionContext( } } } + +/// This returns the default directory in which we store SQLite database files. +func appleDefaultDatabaseDirectory() throws -> URL { + let fileManager = FileManager.default + + // Get the application support directory + guard let documentsDirectory = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw PowerSyncError.operationFailed(message: "Unable to find application support directory") + } + + return documentsDirectory.appendingPathComponent("databases") +} + +/// Deletes all SQLite files for a given database filename in the specified directory. +/// This includes the main database file and WAL mode files (.wal, .shm, and .journal if present). +/// Throws an error if a file exists but could not be deleted. Files that don't exist are ignored. +func deleteSQLiteFiles(dbFilename: String, in directory: URL) throws { + let fileManager = FileManager.default + + // SQLite files to delete: + // 1. Main database file: dbFilename + // 2. WAL file: dbFilename-wal + // 3. SHM file: dbFilename-shm + // 4. Journal file: dbFilename-journal (for rollback journal mode, though WAL mode typically doesn't use it) + + let filesToDelete = [ + dbFilename, + "\(dbFilename)-wal", + "\(dbFilename)-shm", + "\(dbFilename)-journal" + ] + + for filename in filesToDelete { + let fileURL = directory.appendingPathComponent(filename) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + // If file doesn't exist, we ignore it and continue + } +} diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index cd112a5..c71f95f 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -188,7 +188,7 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { /// data by transaction. One batch may contain data from multiple transactions, /// and a single transaction may be split over multiple batches. func getCrudBatch(limit: Int32) async throws -> CrudBatch? - + /// Obtains an async iterator of completed transactions with local writes against the database. /// /// This is typically used from the ``PowerSyncBackendConnectorProtocol/uploadData(database:)`` callback. @@ -228,6 +228,17 @@ public protocol PowerSyncDatabaseProtocol: Queries, Sendable { /// /// Once close is called, this database cannot be used again - a new one must be constructed. func close() async throws + + /// Close the database, releasing resources. + /// Also disconnects any active connection. + /// + /// Once close is called, this database cannot be used again - a new one must be constructed. + /// + /// - Parameter deleteDatabase: Set to true to delete the SQLite database files. Defaults to `false`. + /// + /// - Throws: An error if a database file exists but could not be deleted. Files that don't exist are ignored. + /// This includes the main database file and any WAL mode files (.wal, .shm, .journal). + func close(deleteDatabase: Bool) async throws } public extension PowerSyncDatabaseProtocol { @@ -243,13 +254,13 @@ public extension PowerSyncDatabaseProtocol { /// Unlike `getCrudBatch`, this only returns data from a single transaction at a time. /// All data for the transaction is loaded into memory. func getNextCrudTransaction() async throws -> CrudTransaction? { - for try await transaction in self.getCrudTransactions() { + for try await transaction in getCrudTransactions() { return transaction } - + return nil } - + /// /// The connection is automatically re-opened if it fails for any reason. /// diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 0c3b48e..dd181b8 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -71,7 +71,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { func testGetError() async throws { do { - let _ = try await database.get( + _ = try await database.get( sql: "SELECT id, name, email FROM usersfail WHERE id = ?", parameters: ["1"] ) { cursor in @@ -116,7 +116,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { func testGetOptionalError() async throws { do { - let _ = try await database.getOptional( + _ = try await database.getOptional( sql: "SELECT id, name, email FROM usersfail WHERE id = ?", parameters: ["1"] ) { cursor in @@ -140,7 +140,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { parameters: ["1", "Test User", "test@example.com"] ) do { - let _ = try await database.getOptional( + _ = try await database.getOptional( sql: "SELECT id, name, email FROM users WHERE id = ?", parameters: ["1"] ) { _ throws in @@ -181,7 +181,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { func testGetAllError() async throws { do { - let _ = try await database.getAll( + _ = try await database.getAll( sql: "SELECT id, name, email FROM usersfail WHERE id = ?", parameters: ["1"] ) { cursor in @@ -624,4 +624,77 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(result[0], JoinOutput(name: "Test User", description: "task 1", comment: "comment 1")) XCTAssertEqual(result[1], JoinOutput(name: "Test User", description: "task 2", comment: "comment 2")) } + + func testCloseWithDeleteDatabase() async throws { + let fileManager = FileManager.default + let testDbFilename = "test_delete_\(UUID().uuidString).db" + + // Get the database directory using the helper function + let databaseDirectory = try appleDefaultDatabaseDirectory() + + // Create a database with a real file + let testDatabase = PowerSyncDatabase( + schema: schema, + dbFilename: testDbFilename, + logger: DatabaseLogger(DefaultLogger()) + ) + + // Perform some operations to ensure the database file is created + try await testDatabase.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + // Verify the database file exists + let dbFile = databaseDirectory.appendingPathComponent(testDbFilename) + XCTAssertTrue(fileManager.fileExists(atPath: dbFile.path), "Database file should exist") + + // Close with deleteDatabase: true + try await testDatabase.close(deleteDatabase: true) + + // Verify the database file and related files are deleted + XCTAssertFalse(fileManager.fileExists(atPath: dbFile.path), "Database file should be deleted") + + let walFile = databaseDirectory.appendingPathComponent("\(testDbFilename)-wal") + let shmFile = databaseDirectory.appendingPathComponent("\(testDbFilename)-shm") + let journalFile = databaseDirectory.appendingPathComponent("\(testDbFilename)-journal") + + XCTAssertFalse(fileManager.fileExists(atPath: walFile.path), "WAL file should be deleted") + XCTAssertFalse(fileManager.fileExists(atPath: shmFile.path), "SHM file should be deleted") + XCTAssertFalse(fileManager.fileExists(atPath: journalFile.path), "Journal file should be deleted") + } + + func testCloseWithoutDeleteDatabase() async throws { + let fileManager = FileManager.default + let testDbFilename = "test_no_delete_\(UUID().uuidString).db" + + // Get the database directory using the helper function + let databaseDirectory = try appleDefaultDatabaseDirectory() + + // Create a database with a real file + let testDatabase = PowerSyncDatabase( + schema: schema, + dbFilename: testDbFilename, + logger: DatabaseLogger(DefaultLogger()) + ) + + // Perform some operations to ensure the database file is created + try await testDatabase.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + // Verify the database file exists + let dbFile = databaseDirectory.appendingPathComponent(testDbFilename) + XCTAssertTrue(fileManager.fileExists(atPath: dbFile.path), "Database file should exist") + + // Close with deleteDatabase: false (default) + try await testDatabase.close() + + // Verify the database file still exists + XCTAssertTrue(fileManager.fileExists(atPath: dbFile.path), "Database file should still exist after close without delete") + + // Clean up: delete all SQLite files using the helper function + try deleteSQLiteFiles(dbFilename: testDbFilename, in: databaseDirectory) + } }