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!)" : "")") + } +} +