diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4523c23..b075199 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog
+# 1.0.0-Beta.10 (unreleased)
+
+* Added the ability to specify a custom logging implementation
+```swift
+ let db = PowerSyncDatabase(
+ schema: Schema(
+ tables: [
+ Table(
+ name: "users",
+ columns: [
+ .text("name"),
+ .text("email")
+ ]
+ )
+ ]
+ ),
+ logger: DefaultLogger(minSeverity: .debug)
+)
+```
+* added `.close()` method on `PowerSyncDatabaseProtocol`
+
## 1.0.0-Beta.9
* Update PowerSync SQLite core extension to 0.3.12.
diff --git a/README.md b/README.md
index 40a4849..682258c 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-*[PowerSync](https://www.powersync.com) is a sync engine for building local-first apps with instantly-responsive UI/UX and simplified state transfer. Syncs between SQLite on the client-side and Postgres, MongoDB or MySQL on the server-side.*
+_[PowerSync](https://www.powersync.com) is a sync engine for building local-first apps with instantly-responsive UI/UX and simplified state transfer. Syncs between SQLite on the client-side and Postgres, MongoDB or MySQL on the server-side._
# PowerSync Swift
@@ -16,7 +16,7 @@ This SDK is currently in a beta release it is suitable for production use, given
- [Sources](./Sources/)
- - This is the Swift SDK implementation.
+ - This is the Swift SDK implementation.
## Demo Apps / Example Projects
@@ -51,11 +51,35 @@ to your `Package.swift` file and pin the dependency to a specific version. The v
to your `Package.swift` file and pin the dependency to a specific version. This is required because the package is in beta.
+## Usage
+
+Create a PowerSync client
+
+```swift
+import PowerSync
+
+let powersync = PowerSyncDatabase(
+ schema: Schema(
+ tables: [
+ Table(
+ name: "users",
+ columns: [
+ .text("count"),
+ .integer("is_active"),
+ .real("weight"),
+ .text("description")
+ ]
+ )
+ ]
+ ),
+ logger: DefaultLogger(minSeverity: .debug)
+)
+```
+
## Underlying Kotlin Dependency
The PowerSync Swift SDK currently makes use of the [PowerSync Kotlin Multiplatform SDK](https://github.com/powersync-ja/powersync-kotlin) with the API tool [SKIE](https://skie.touchlab.co/) and KMMBridge under the hood to help generate and publish a native Swift package. We will move to an entirely Swift native API in v1 and do not expect there to be any breaking changes. For more details, see the [Swift SDK reference](https://docs.powersync.com/client-sdk-references/swift).
-
## Migration from Alpha to Beta
See these [developer notes](https://docs.powersync.com/client-sdk-references/swift#migrating-from-the-alpha-to-the-beta-sdk) if you are migrating from the alpha to the beta version of the Swift SDK.
diff --git a/Sources/PowerSync/Kotlin/DatabaseLogger.swift b/Sources/PowerSync/Kotlin/DatabaseLogger.swift
new file mode 100644
index 0000000..21229d1
--- /dev/null
+++ b/Sources/PowerSync/Kotlin/DatabaseLogger.swift
@@ -0,0 +1,88 @@
+import PowerSyncKotlin
+
+/// Adapts a Swift `LoggerProtocol` to Kermit's `LogWriter` interface.
+///
+/// This allows Kotlin logging (via Kermit) to call into the Swift logging implementation.
+private class KermitLogWriterAdapter: Kermit_coreLogWriter {
+ /// The underlying Swift log writer to forward log messages to.
+ let logger: any LoggerProtocol
+
+ /// Initializes a new adapter.
+ ///
+ /// - Parameter logger: A Swift log writer that will handle log output.
+ init(logger: any LoggerProtocol) {
+ self.logger = logger
+ super.init()
+ }
+
+ /// Called by Kermit to log a message.
+ ///
+ /// - Parameters:
+ /// - severity: The severity level of the log.
+ /// - message: The content of the log message.
+ /// - tag: A string categorizing the log.
+ /// - throwable: An optional Kotlin exception (ignored here).
+ override func log(severity: Kermit_coreSeverity, message: String, tag: String, throwable: KotlinThrowable?) {
+ switch severity {
+ case PowerSyncKotlin.Kermit_coreSeverity.verbose:
+ return logger.debug(message, tag: tag)
+ case PowerSyncKotlin.Kermit_coreSeverity.debug:
+ return logger.debug(message, tag: tag)
+ case PowerSyncKotlin.Kermit_coreSeverity.info:
+ return logger.info(message, tag: tag)
+ case PowerSyncKotlin.Kermit_coreSeverity.warn:
+ return logger.warning(message, tag: tag)
+ case PowerSyncKotlin.Kermit_coreSeverity.error:
+ return logger.error(message, tag: tag)
+ case PowerSyncKotlin.Kermit_coreSeverity.assert:
+ return logger.fault(message, tag: tag)
+ }
+ }
+}
+
+/// A logger implementation that integrates with PowerSync's Kotlin core using Kermit.
+///
+/// This class bridges Swift log writers with the Kotlin logging system and supports
+/// runtime configuration of severity levels and writer lists.
+internal class DatabaseLogger: LoggerProtocol {
+ /// The underlying Kermit logger instance provided by the PowerSyncKotlin SDK.
+ public let kLogger = PowerSyncKotlin.generateLogger(logger: nil)
+ public let logger: any LoggerProtocol
+
+ /// Initializes a new logger with an optional list of writers.
+ ///
+ /// - Parameter logger: A logger which will be called for each internal log operation
+ init(_ logger: any LoggerProtocol) {
+ self.logger = logger
+ // Set to the lowest severity. The provided logger should filter by severity
+ kLogger.mutableConfig.setMinSeverity(Kermit_coreSeverity.verbose)
+ kLogger.mutableConfig.setLogWriterList(
+ [KermitLogWriterAdapter(logger: logger)]
+ )
+ }
+
+ /// Logs a debug-level message.
+ public func debug(_ message: String, tag: String?) {
+ logger.debug(message, tag: tag)
+ }
+
+ /// Logs an info-level message.
+ public func info(_ message: String, tag: String?) {
+ logger.info(message, tag: tag)
+ }
+
+ /// Logs a warning-level message.
+ public func warning(_ message: String, tag: String?) {
+ logger.warning(message, tag: tag)
+ }
+
+ /// Logs an error-level message.
+ public func error(_ message: String, tag: String?) {
+ logger.error(message, tag: tag)
+ }
+
+ /// Logs a fault (assert-level) message, typically used for critical issues.
+ public func fault(_ message: String, tag: String?) {
+ logger.fault(message, tag: tag)
+ }
+}
diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
index efd3b5e..ea78b55 100644
--- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
+++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
@@ -8,13 +8,15 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
init(
schema: Schema,
- dbFilename: String
+ dbFilename: String,
+ logger: DatabaseLogger? = nil
) {
let factory = PowerSyncKotlin.DatabaseDriverFactory()
kotlinDatabase = PowerSyncDatabase(
factory: factory,
schema: KotlinAdapter.Schema.toKotlin(schema),
- dbFilename: dbFilename
+ dbFilename: dbFilename,
+ logger: logger?.kLogger
)
}
@@ -232,4 +234,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
func readTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R {
return try safeCast(await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)), to: R.self)
}
+
+ func close() async throws{
+ try await kotlinDatabase.close()
+ }
}
diff --git a/Sources/PowerSync/Logger.swift b/Sources/PowerSync/Logger.swift
new file mode 100644
index 0000000..988d013
--- /dev/null
+++ b/Sources/PowerSync/Logger.swift
@@ -0,0 +1,112 @@
+import OSLog
+
+/// A log writer which prints to the standard output
+///
+/// This writer uses `os.Logger` on iOS/macOS/tvOS/watchOS 14+ and falls back to `print` for earlier versions.
+public class PrintLogWriter: LogWriterProtocol {
+
+ private let subsystem: String
+ private let category: String
+ private lazy var logger: Any? = {
+ if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
+ return Logger(subsystem: subsystem, category: category)
+ }
+ return nil
+ }()
+
+ /// Creates a new PrintLogWriter
+ /// - Parameters:
+ /// - subsystem: The subsystem identifier (typically reverse DNS notation of your app)
+ /// - category: The category within your subsystem
+ public init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.powersync.logger",
+ category: String = "default") {
+ self.subsystem = subsystem
+ self.category = category
+ }
+
+ /// Logs a message with a given severity and optional tag.
+ /// - Parameters:
+ /// - severity: The severity level of the message.
+ /// - message: The content of the log message.
+ /// - tag: An optional tag used to categorize the message. If empty, no brackets are shown.
+ public func log(severity: LogSeverity, message: String, tag: String?) {
+ let tagPrefix = tag.map { !$0.isEmpty ? "[\($0)] " : "" } ?? ""
+ let formattedMessage = "\(tagPrefix)\(message)"
+
+ if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
+ guard let logger = logger as? Logger else { return }
+
+ switch severity {
+ case .info:
+ logger.info("\(formattedMessage, privacy: .public)")
+ case .error:
+ logger.error("\(formattedMessage, privacy: .public)")
+ case .debug:
+ logger.debug("\(formattedMessage, privacy: .public)")
+ case .warning:
+ logger.warning("\(formattedMessage, privacy: .public)")
+ case .fault:
+ logger.fault("\(formattedMessage, privacy: .public)")
+ }
+ } else {
+ print("\(severity.stringValue): \(formattedMessage)")
+ }
+ }
+}
+
+
+
+/// A default logger configuration that uses `PrintLogWritter` and filters messages by minimum severity.
+public class DefaultLogger: LoggerProtocol {
+ public var minSeverity: LogSeverity
+ public var writers: [any LogWriterProtocol]
+
+ /// Initializes the default logger with an optional minimum severity level.
+ ///
+ /// - Parameters
+ /// - minSeverity: The minimum severity level to log. Defaults to `.debug`.
+ /// - writers: Optional writers which logs should be written to. Defaults to a `PrintLogWriter`.
+ public init(minSeverity: LogSeverity = .debug, writers: [any LogWriterProtocol]? = nil ) {
+ self.writers = writers ?? [ PrintLogWriter() ]
+ self.minSeverity = minSeverity
+ }
+
+ public func setWriters(_ writters: [any LogWriterProtocol]) {
+ self.writers = writters
+ }
+
+ public func setMinSeverity(_ severity: LogSeverity) {
+ self.minSeverity = severity
+ }
+
+
+ public func debug(_ message: String, tag: String? = nil) {
+ self.writeLog(message, severity: LogSeverity.debug, tag: tag)
+ }
+
+ public func error(_ message: String, tag: String? = nil) {
+ self.writeLog(message, severity: LogSeverity.error, tag: tag)
+ }
+
+ public func info(_ message: String, tag: String? = nil) {
+ self.writeLog(message, severity: LogSeverity.info, tag: tag)
+ }
+
+ public func warning(_ message: String, tag: String? = nil) {
+ self.writeLog(message, severity: LogSeverity.warning, tag: tag)
+ }
+
+ public func fault(_ message: String, tag: String? = nil) {
+ self.writeLog(message, severity: LogSeverity.fault, tag: tag)
+ }
+
+ private func writeLog(_ message: String, severity: LogSeverity, tag: String?) {
+ if (severity.rawValue < self.minSeverity.rawValue) {
+ return
+ }
+
+ for writer in self.writers {
+ writer.log(severity: severity, message: message, tag: tag)
+ }
+ }
+}
diff --git a/Sources/PowerSync/LoggerProtocol.swift b/Sources/PowerSync/LoggerProtocol.swift
new file mode 100644
index 0000000..f2c3396
--- /dev/null
+++ b/Sources/PowerSync/LoggerProtocol.swift
@@ -0,0 +1,85 @@
+public enum LogSeverity: Int, CaseIterable {
+ /// Detailed information typically used for debugging.
+ case debug = 0
+
+ /// Informational messages that highlight the progress of the application.
+ case info = 1
+
+ /// Potentially harmful situations that are not necessarily errors.
+ case warning = 2
+
+ /// Error events that might still allow the application to continue running.
+ case error = 3
+
+ /// Serious errors indicating critical failures, often unrecoverable.
+ case fault = 4
+
+ /// Map severity to its string representation
+ public var stringValue: String {
+ switch self {
+ case .debug: return "DEBUG"
+ case .info: return "INFO"
+ case .warning: return "WARNING"
+ case .error: return "ERROR"
+ case .fault: return "FAULT"
+ }
+ }
+
+ /// Convert Int to String representation
+ public static func string(from intValue: Int) -> String? {
+ return LogSeverity(rawValue: intValue)?.stringValue
+ }
+}
+
+/// A protocol for writing log messages to a specific backend or output.
+///
+/// Conformers handle the actual writing or forwarding of log messages.
+public protocol LogWriterProtocol {
+ /// Logs a message with the given severity and optional tag.
+ ///
+ /// - Parameters:
+ /// - severity: The severity level of the log message.
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize or group the log message.
+ func log(severity: LogSeverity, message: String, tag: String?)
+}
+
+/// A protocol defining the interface for a logger that supports severity filtering and multiple writers.
+///
+/// Conformers provide logging APIs and manage attached log writers.
+public protocol LoggerProtocol {
+ /// Logs an informational message.
+ ///
+ /// - Parameters:
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize the message.
+ func info(_ message: String, tag: String?)
+
+ /// Logs an error message.
+ ///
+ /// - Parameters:
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize the message.
+ func error(_ message: String, tag: String?)
+
+ /// Logs a debug message.
+ ///
+ /// - Parameters:
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize the message.
+ func debug(_ message: String, tag: String?)
+
+ /// Logs a warning message.
+ ///
+ /// - Parameters:
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize the message.
+ func warning(_ message: String, tag: String?)
+
+ /// Logs a fault message, typically used for critical system-level failures.
+ ///
+ /// - Parameters:
+ /// - message: The content of the log message.
+ /// - tag: An optional tag to categorize the message.
+ func fault(_ message: String, tag: String?)
+}
diff --git a/Sources/PowerSync/PowerSyncDatabase.swift b/Sources/PowerSync/PowerSyncDatabase.swift
index 454e7e3..dbabf92 100644
--- a/Sources/PowerSync/PowerSyncDatabase.swift
+++ b/Sources/PowerSync/PowerSyncDatabase.swift
@@ -7,14 +7,17 @@ public let DEFAULT_DB_FILENAME = "powersync.db"
/// - Parameters:
/// - schema: The database schema
/// - dbFilename: The database filename. Defaults to "powersync.db"
+/// - logger: Optional logging interface
/// - Returns: A configured PowerSyncDatabase instance
public func PowerSyncDatabase(
schema: Schema,
- dbFilename: String = DEFAULT_DB_FILENAME
+ dbFilename: String = DEFAULT_DB_FILENAME,
+ logger: (any LoggerProtocol) = DefaultLogger()
) -> PowerSyncDatabaseProtocol {
return KotlinPowerSyncDatabaseImpl(
schema: schema,
- dbFilename: dbFilename
+ dbFilename: dbFilename,
+ logger: DatabaseLogger(logger)
)
}
diff --git a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift
index ea5418c..9de5f00 100644
--- a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift
+++ b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift
@@ -100,6 +100,12 @@ public protocol PowerSyncDatabaseProtocol: Queries {
///
/// - Parameter clearLocal: Set to false to preserve data in local-only tables.
func disconnectAndClear(clearLocal: Bool) 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.
+ func close() async throws
}
public extension PowerSyncDatabaseProtocol {
diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift
index 3c645c5..a97ff9f 100644
--- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift
+++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift
@@ -16,13 +16,20 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase {
database = KotlinPowerSyncDatabaseImpl(
schema: schema,
- dbFilename: ":memory:"
+ dbFilename: ":memory:",
+ logger: DatabaseLogger(DefaultLogger())
)
try await database.disconnectAndClear()
}
override func tearDown() async throws {
try await database.disconnectAndClear()
+ // Tests currently fail if this is called.
+ // The watched query tests try and read from the DB while it's closing.
+ // This causes a PowerSyncException to be thrown in the Kotlin flow.
+ // Custom exceptions in flows are not supported by SKIEE. This causes a crash.
+ // FIXME: Reapply once watched query errors are handled better.
+ // try await database.close()
database = nil
try await super.tearDown()
}
@@ -468,4 +475,47 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase {
XCTAssertEqual(peopleCount, 1)
}
+
+ func testCustomLogger() async throws {
+ let testWriter = TestLogWriterAdapter()
+ let logger = DefaultLogger(minSeverity: LogSeverity.debug, writers: [testWriter])
+
+ let db2 = KotlinPowerSyncDatabaseImpl(
+ schema: schema,
+ dbFilename: ":memory:",
+ logger: DatabaseLogger(logger)
+ )
+
+ try await db2.close()
+
+ let warningIndex = testWriter.logs.firstIndex(
+ where: { value in
+ value.contains("warning: Multiple PowerSync instances for the same database have been detected")
+ }
+ )
+
+ XCTAssert(warningIndex! >= 0)
+ }
+
+ func testMinimumSeverity() async throws {
+ let testWriter = TestLogWriterAdapter()
+ let logger = DefaultLogger(minSeverity: LogSeverity.error, writers: [testWriter])
+
+ let db2 = KotlinPowerSyncDatabaseImpl(
+ schema: schema,
+ dbFilename: ":memory:",
+ logger: DatabaseLogger(logger)
+ )
+
+ try await db2.close()
+
+ let warningIndex = testWriter.logs.firstIndex(
+ where: { value in
+ value.contains("warning: Multiple PowerSync instances for the same database have been detected")
+ }
+ )
+
+ // The warning should not be present due to the min severity
+ XCTAssert(warningIndex == nil)
+ }
}
diff --git a/Tests/PowerSyncTests/Kotlin/TestLogger.swift b/Tests/PowerSyncTests/Kotlin/TestLogger.swift
new file mode 100644
index 0000000..bff9ad1
--- /dev/null
+++ b/Tests/PowerSyncTests/Kotlin/TestLogger.swift
@@ -0,0 +1,11 @@
+@testable import PowerSync
+
+
+class TestLogWriterAdapter: LogWriterProtocol {
+ var logs = [String]()
+
+ func log(severity: LogSeverity, message: String, tag: String?) {
+ logs.append("\(severity): \(message) \(tag != nil ? "\(tag!)" : "")")
+ }
+}
+