Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
62 changes: 61 additions & 1 deletion Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol,
logger: logger.kLogger
)
self.logger = logger
self.dbFilename = dbFilename
currentStatus = KotlinSyncStatus(
baseStatus: kotlinDatabase.currentStatus
)
Expand Down Expand Up @@ -74,7 +76,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol,
batch: base
)
}

func getCrudTransactions() -> any CrudTransactions {
return KotlinCrudTransactions(db: kotlinDatabase)
}
Expand Down Expand Up @@ -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<R: Sendable>(
handler: () async throws -> R)
Expand Down Expand Up @@ -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
}
}
19 changes: 15 additions & 4 deletions Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
///
Expand Down
81 changes: 77 additions & 4 deletions Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}