diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 1e81c64..33eb9b0 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -2,15 +2,17 @@ import Foundation import DataLiteCore import DataLiteC -/// A base class for services that operate on a database connection. +/// Base service for working with a database. /// -/// `DatabaseService` provides a shared interface for executing operations on a `Connection`, -/// with support for transaction handling and optional request serialization. +/// `DatabaseService` provides a unified interface for performing operations +/// using a database connection, with built-in support for transactions, +/// reconnection, and optional encryption key management. /// -/// Subclasses can use this base to coordinate safe, synchronous access to the database -/// without duplicating concurrency or transaction logic. +/// The service ensures thread-safe execution by serializing access to the +/// connection through an internal queue. This enables building modular and safe +/// data access layers without duplicating low-level logic. /// -/// For example, you can define a custom service for managing notes: +/// Below is an example of creating a service for managing notes: /// /// ```swift /// final class NoteService: DatabaseService { @@ -46,59 +48,40 @@ import DataLiteC /// print(notes) // ["Hello, world!"] /// ``` /// -/// This approach allows you to build reusable service layers on top of a safe, transactional, -/// and serialized foundation. -/// /// ## Error Handling /// -/// All database access is serialized using an internal dispatch queue to ensure thread safety. -/// If a database corruption or decryption failure is detected (e.g., `SQLITE_NOTADB`), the -/// service attempts to re-establish the connection and, in case of transaction blocks, -/// retries the entire transaction block exactly once. If the problem persists, the error -/// is rethrown. +/// All operations are executed on an internal serial queue, ensuring thread safety. +/// If an encryption error (`SQLITE_NOTADB`) is detected, the service may reopen the +/// connection and retry the transactional block exactly once. If the error occurs again, +/// it is propagated without further retries. /// /// ## Encryption Key Management /// -/// If a `keyProvider` is set, the service will use it to retrieve and apply encryption keys -/// when establishing or re-establishing a database connection. Any error that occurs while -/// retrieving or applying the encryption key is reported to the provider via -/// `databaseService(_:didReceive:)`. Non-encryption-related errors (e.g., file access -/// issues) are not reported to the provider. +/// If a ``keyProvider`` is set, the service uses it to obtain and apply an encryption +/// key when creating or restoring a connection. If an error occurs while obtaining +/// or applying the key, the provider is notified through +/// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. /// -/// ## Reconnect Behavior +/// ## Reconnection /// -/// The service can automatically reconnect to the database, but this happens only in very specific -/// circumstances. Reconnection is triggered only when you run a transactional operation using -/// ``perform(in:closure:)``, and a decryption error (`SQLITE_NOTADB`) occurs during -/// the transaction. Even then, reconnection is possible only if you have set a ``keyProvider``, -/// and only if the provider allows it by returning `true` from its -/// ``DatabaseServiceKeyProvider/databaseServiceShouldReconnect(_:)-84qfz`` -/// method. -/// -/// When this happens, the service will ask the key provider for a new encryption key, create a new -/// database connection, and then try to re-run your transaction block one more time. If the second -/// attempt also fails with the same decryption error, or if reconnection is not allowed, the error is -/// returned to your code as usual, and no further attempts are made. -/// -/// It's important to note that reconnection and retrying of transactions never happens outside of -/// transactional operations, and will never be triggered for other types of errors. All of this logic -/// runs on the service’s internal queue, so you don’t have to worry about thread safety. -/// -/// - Important: Because a transaction block can be executed more than once when this -/// mechanism is triggered, make sure that your block is idempotent and doesn't cause any -/// side effects outside the database itself. +/// Automatic reconnection is available only during transactional blocks executed with +/// ``perform(in:closure:)``. If a decryption error (`SQLITE_NOTADB`) occurs during +/// a transaction and the provider allows reconnection, the service obtains a new key, +/// creates a new connection, and retries the block once. If the second attempt fails +/// or reconnection is disallowed, the error is propagated without further retries. /// /// ## Topics /// /// ### Initializers /// -/// - ``init(provider:queue:)`` -/// - ``init(connection:queue:)`` +/// - ``init(provider:keyProvider:queue:)`` +/// - ``init(connection:keyProvider:queue:)`` /// /// ### Key Management /// /// - ``DatabaseServiceKeyProvider`` /// - ``keyProvider`` +/// - ``applyKeyProvider()`` /// /// ### Connection Management /// @@ -111,14 +94,14 @@ import DataLiteC /// - ``perform(_:)`` /// - ``perform(in:closure:)`` open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { - /// A closure that provides a new database connection when invoked. + /// A closure that creates a new database connection. /// - /// `ConnectionProvider` is used to defer the creation of a `Connection` instance - /// until it is actually needed. It can throw errors if the connection cannot be - /// established or configured correctly. + /// `ConnectionProvider` is used for deferred connection creation. + /// It allows encapsulating initialization logic, configuration, and + /// error handling when opening the database. /// - /// - Returns: A valid `Connection` instance. - /// - Throws: Any error encountered while opening or configuring the connection. + /// - Returns: An initialized `Connection` instance. + /// - Throws: An error if the connection cannot be created or configured. public typealias ConnectionProvider = () throws -> Connection // MARK: - Properties @@ -128,91 +111,96 @@ open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { private let queueKey = DispatchSpecificKey() private var connection: Connection - /// Provides the encryption key for the database connection. + /// Encryption key provider. /// - /// When this property is set, the service synchronously retrieves and applies an encryption - /// key from the provider to the current database connection on the service’s internal queue, - /// ensuring thread safety. - /// - /// If an error occurs during key retrieval or application (for example, if biometric - /// authentication is cancelled, the key is unavailable, or decryption fails due to an - /// incorrect key), the service notifies the provider by calling - /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)-xbrk``. - /// - /// This mechanism enables external management of encryption keys, supporting scenarios such - /// as key rotation, user-specific encryption, or custom error handling. - public weak var keyProvider: DatabaseServiceKeyProvider? { - didSet { - withConnection { connection in - try? applyKey(to: connection) - } - } - } + /// Used to obtain and apply a key when creating or restoring a connection. + public weak var keyProvider: DatabaseServiceKeyProvider? // MARK: - Inits - /// Creates a new `DatabaseService` with the specified connection provider and dispatch queue. + /// Creates a new database service. /// - /// This initializer immediately invokes the `provider` closure to establish the initial database - /// connection. An internal serial queue is created for synchronizing database access. If a - /// `queue` is provided, it is set as the target of the internal queue, allowing you to control - /// scheduling and quality of service. + /// Calls `provider` to create the initial connection and configures + /// the internal serial queue for thread-safe access to the database. + /// + /// The internal queue is always created with QoS `.utility`. If the `queue` + /// parameter is provided, it is used as the target queue for the internal one. + /// + /// If a `keyProvider` is set, the encryption key is applied immediately + /// after the initial connection is created. /// /// - Parameters: - /// - provider: A closure that returns a new `Connection` instance. May throw on failure. - /// - queue: An optional dispatch queue to target for internal serialization. If `nil`, - /// a dedicated serial queue with `.utility` QoS is created. - /// - Throws: Any error thrown by the `provider` during initial connection setup. + /// - provider: A closure that returns a new connection. + /// - keyProvider: An optional encryption key provider. + /// - queue: An optional target queue for the internal one. + /// - Throws: An error if the connection cannot be created or configured. public init( provider: @escaping ConnectionProvider, + keyProvider: DatabaseServiceKeyProvider? = nil, queue: DispatchQueue? = nil - ) rethrows { + ) throws { self.provider = provider + self.keyProvider = keyProvider self.connection = try provider() self.queue = .init(for: Self.self, qos: .utility) self.queue.setSpecific(key: queueKey, value: ()) if let queue = queue { self.queue.setTarget(queue: queue) } + if self.keyProvider != nil { + try applyKey(to: self.connection) + } } - /// Creates a new `DatabaseService` using the given connection provider and optional queue. - /// - /// This convenience initializer wraps the provided autoclosure in a `ConnectionProvider` - /// and delegates to the designated initializer. It is useful when passing a simple - /// connection expression. + /// Creates a new database service. /// /// - Parameters: - /// - provider: A closure that returns a `Connection` instance and may throw. - /// - queue: An optional dispatch queue used as a target for internal serialization. If `nil`, - /// a default serial queue with `.utility` QoS is created internally. - /// - Throws: Rethrows any error thrown by the connection provider. + /// - provider: An expression that creates a new connection. + /// - keyProvider: An optional encryption key provider. + /// - queue: An optional target queue for the internal one. + /// - Throws: An error if the connection cannot be created or configured. public convenience init( connection provider: @escaping @autoclosure ConnectionProvider, + keyProvider: DatabaseServiceKeyProvider? = nil, queue: DispatchQueue? = nil - ) rethrows { - try self.init(provider: provider, queue: queue) + ) throws { + try self.init(provider: provider, keyProvider: keyProvider, queue: queue) } // MARK: - Methods - /// Re-establishes the database connection using the stored connection provider. + /// Applies the encryption key from `keyProvider` to the current connection. /// - /// This method synchronously creates a new ``Connection`` instance by invoking the original - /// provider on the service’s internal queue. If a ``keyProvider`` is set, the service attempts - /// to retrieve and apply an encryption key to the new connection. - /// If any error occurs during key retrieval or application, the provider is notified via - /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)-xbrk``, - /// and the error is rethrown. + /// The method executes synchronously on the internal queue. If the key provider + /// is missing, the method does nothing. If the key has already been successfully + /// applied, subsequent calls have no effect. To apply a new key, use ``reconnect()``. /// - /// The new connection replaces the existing one only if all steps succeed without errors. + /// If an error occurs while obtaining or applying the key, it is thrown further + /// and also reported to the provider via + /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. /// - /// This operation is always executed on the internal dispatch queue (see ``perform(_:)``) - /// to ensure thread safety. + /// - Throws: An error while obtaining or applying the key. + final public func applyKeyProvider() throws { + try withConnection { connection in + try applyKey(to: connection) + } + } + + /// Establishes a new database connection. /// - /// - Throws: Any error thrown during connection creation or while retrieving or applying - /// the encryption key. Only encryption-related errors are reported to the ``keyProvider``. - public func reconnect() throws { + /// Creates a new `Connection` using the stored connection provider and, + /// if a ``keyProvider`` is set, applies the encryption key. The new connection + /// replaces the previous one only if it is successfully created and configured. + /// + /// If an error occurs while obtaining or applying the key, it is thrown further + /// and also reported to the provider via + /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. + /// + /// Executed synchronously on the internal queue, ensuring thread safety. + /// + /// - Throws: An error if the connection cannot be created or the key cannot + /// be obtained/applied. + final public func reconnect() throws { try withConnection { _ in let connection = try provider() try applyKey(to: connection) @@ -220,39 +208,39 @@ open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { } } - /// Executes the given closure using the active database connection. + /// Executes a closure with the active connection. /// - /// Ensures thread-safe access to the underlying ``Connection`` by synchronizing execution on - /// the service’s internal serial dispatch queue. If the call is already running on this queue, - /// the closure is executed directly to avoid unnecessary dispatching. + /// Runs the `closure` on the internal serial queue, ensuring + /// thread-safe access to the `Connection`. /// - /// - Parameter closure: A closure that takes the active connection and returns a result. + /// - Parameter closure: A closure that takes the active connection. /// - Returns: The value returned by the closure. /// - Throws: Any error thrown by the closure. - public func perform(_ closure: Perform) rethrows -> T { + final public func perform(_ closure: Perform) rethrows -> T { try withConnection(closure) } /// Executes a closure inside a transaction if the connection is in autocommit mode. /// - /// If the connection is in autocommit mode, starts a new transaction of the specified type, - /// executes the closure within it, and commits the transaction on success. If the closure - /// throws, the transaction is rolled back. + /// If the connection is in autocommit mode, starts a new transaction of the + /// specified type, executes the closure, and commits changes on success. + /// If the closure throws an error, the transaction is rolled back. /// - /// If the closure throws a `Connection.Error` with code `SQLITE_NOTADB` and reconnecting is - /// allowed, the service attempts to reconnect and retries the entire transaction block once. + /// If the closure throws `Connection.Error` with code `SQLITE_NOTADB` + /// and reconnection is allowed, the service attempts to reconnect and retries + /// the transaction block once. /// - /// If already inside a transaction (not in autocommit mode), executes the closure directly - /// without starting a new transaction. + /// If a transaction is already active (connection not in autocommit mode), + /// the closure is executed directly without starting a new transaction. /// /// - Parameters: - /// - transaction: The type of transaction to begin. + /// - transaction: The type of transaction to start. /// - closure: A closure that takes the active connection and returns a result. /// - Returns: The value returned by the closure. - /// - Throws: Any error thrown by the closure, transaction control statements, or reconnect logic. - /// + /// - Throws: Any error thrown by the closure, transaction management, or + /// reconnection logic. /// - Important: The closure may be executed more than once. Ensure it is idempotent. - public func perform( + final public func perform( in transaction: TransactionType, closure: Perform ) rethrows -> T { @@ -291,9 +279,11 @@ open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { } } +// MARK: - Private + private extension DatabaseService { var shouldReconnect: Bool { - keyProvider?.databaseServiceShouldReconnect(self) ?? false + keyProvider?.databaseService(shouldReconnect: self) ?? false } func withConnection(_ closure: Perform) rethrows -> T { @@ -304,14 +294,15 @@ private extension DatabaseService { } func applyKey(to connection: Connection) throws { + guard let keyProvider = keyProvider else { return } do { - if let key = try keyProvider?.databaseServiceKey(self) { + if let key = try keyProvider.databaseService(keyFor: self) { let sql = "SELECT count(*) FROM sqlite_master" try connection.apply(key) try connection.execute(raw: sql) } } catch { - keyProvider?.databaseService(self, didReceive: error) + keyProvider.databaseService(self, didReceive: error) throw error } } diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift index 5c564da..3b5190c 100644 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -74,6 +74,16 @@ public final class MigrationService< pthread_mutex_destroy(&mutex) } + /// Applies settings to the active database connection. + public func applyKeyProvider() throws { + try service.applyKeyProvider() + } + + /// Recreates the database connection. + public func reconnect() throws { + try service.reconnect() + } + /// Registers a new migration, ensuring version and script URL uniqueness. /// /// - Parameter migration: The migration to register. diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift index 993bea8..df7b7e5 100644 --- a/Sources/DataRaft/Classes/RowDatabaseService.swift +++ b/Sources/DataRaft/Classes/RowDatabaseService.swift @@ -78,7 +78,7 @@ open class RowDatabaseService: encoder: RowEncoder = RowEncoder(), decoder: RowDecoder = RowDecoder(), queue: DispatchQueue? = nil - ) rethrows { + ) throws { try self.init( provider: provider, encoder: encoder, @@ -104,7 +104,7 @@ open class RowDatabaseService: encoder: RowEncoder = RowEncoder(), decoder: RowDecoder = RowDecoder(), queue: DispatchQueue? = nil - ) rethrows { + ) throws { self.encoder = encoder self.decoder = decoder try super.init( diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift index 00d75d2..d83c671 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift @@ -1,124 +1,53 @@ import Foundation import DataLiteCore -/// A protocol for supplying encryption keys to `DatabaseService` instances. +/// A protocol for providing encryption keys to a database service. /// -/// `DatabaseServiceKeyProvider` encapsulates all responsibilities for managing encryption keys -/// for one or more `DatabaseService` instances. It allows a database service to delegate key -/// retrieval, secure storage, rotation, and access control, enabling advanced security strategies -/// such as per-user key derivation, hardware-backed keys, biometric authentication, or ephemeral -/// in-memory secrets. +/// `DatabaseServiceKeyProvider` is responsible for managing encryption keys used +/// by a database service. This makes it possible to implement different strategies for storing +/// and retrieving keys: static, dynamic, hardware-backed, biometric, and others. /// -/// The provider is queried automatically by the database service whenever a new connection -/// is created or re-established (for example, during service initialization, after a reconnect, -/// or when the service requests a key rotation). +/// - The service requests a key when establishing or restoring a connection. +/// - If decryption fails, the service may ask the provider whether it should attempt to reconnect. +/// - If applying a key fails (for example, the key does not match or the +/// ``databaseService(keyFor:)`` method throws an error), this error is reported +/// to the provider through ``databaseService(_:didReceive:)``. /// -/// Error handling and diagnostics related specifically to encryption or key operations -/// (such as when a key is unavailable, authentication is denied, or decryption fails) -/// are reported to the provider via the optional ``databaseService(_:didReceive:)`` callback. -/// The provider is **not** notified of generic database or connection errors unrelated to -/// encryption. +/// - Important: The provider does not receive notifications about general database errors. /// -/// - Important: This protocol is **exclusively** for cryptographic key management. -/// It must not be used for generic database error handling or for concerns unrelated to -/// encryption, authorization, or key lifecycle. +/// ## Topics /// -/// ## Key Availability +/// ### Instance Methods /// -/// There are two distinct scenarios for returning a key: -/// -/// - **No Encryption Needed:** -/// Return `nil` if the target database does not require encryption (i.e., should be opened -/// in plaintext mode). This is not an error; the database service will attempt to open the -/// database without a key. If the database is in fact encrypted, this will result in a -/// decryption error at the SQLite level (e.g., `SQLITE_NOTADB`), which is handled by the -/// database service as a normal failure. -/// -/// - **Key Temporarily Unavailable:** -/// Also return `nil` if the key is *temporarily* unavailable for any reason (for example, -/// the user has not yet authenticated, the device is locked, a remote key is still loading, -/// or UI authorization has not been granted). -/// Returning `nil` in this case means the database service will not attempt to open -/// the database with a key. This will not trigger an error callback. -/// When the key later becomes available (for example, after user authentication or -/// successful network retrieval), **the provider is responsible for calling** -/// ``DatabaseService/reconnect()`` on the service to re-attempt the operation with the key. -/// -/// - **Error Situations:** -/// Only throw an error if a *permanent* or *unexpected* failure occurs (for example, -/// a hardware security error, a fatal storage problem, or a cryptographic failure -/// that cannot be resolved by waiting or user action). -/// Thrown errors will be reported to the provider via the error callback, and may be -/// surfaced to the UI or logs. -/// -/// - Tip: Never throw for temporary unavailability (such as "user has not unlocked" or -/// "still waiting for user action")—just return `nil` in these cases. -/// Use thrown errors only for non-recoverable or unexpected failures. -/// -/// ## Error Callback -/// -/// The method ``databaseService(_:didReceive:)`` will be called only for errors thrown by -/// ``databaseServiceKey(_:)`` or by the key application process (such as if the key fails -/// to decrypt the database). -/// It will *not* be called for generic database or connection errors. -/// -/// Implement this method if you wish to log, recover from, or respond to permanent key-related -/// failures (such as prompting the user, resetting state, or displaying errors). -public protocol DatabaseServiceKeyProvider: AnyObject { - /// Returns the encryption key to be applied to the given database service. +/// - ``databaseService(keyFor:)`` +/// - ``databaseService(shouldReconnect:)`` +/// - ``databaseService(_:didReceive:)`` +public protocol DatabaseServiceKeyProvider: AnyObject, Sendable { + /// Returns the encryption key for the specified database service. /// - /// This method is invoked by the `DatabaseService` during connection initialization, - /// reconnection, or explicit key rotation. Implementations may return a static key, - /// derive it from external data, fetch it from secure hardware, or perform required - /// user authentication. + /// May return `nil` if the encryption key is currently unavailable or if the database + /// does not require encryption. /// - /// - Parameter service: The requesting database service. - /// - Returns: A `Connection.Key` representing the encryption key, or `nil` if encryption is - /// not required for this database or the key is temporarily unavailable. Returning `nil` - /// will cause the database service to attempt opening the database in plaintext mode. - /// If the database is actually encrypted, access will fail with a decryption error. - /// - Throws: Only throw for unrecoverable or unexpected errors (such as hardware failure, - /// fatal storage issues, or irrecoverable cryptographic errors). Do **not** throw for - /// temporary unavailability; instead, return `nil` and call ``DatabaseService/reconnect()`` - /// later when the key becomes available. - /// - /// - Note: This method may be called multiple times during the lifecycle of a service, - /// including after a failed decryption attempt or key rotation event. - func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key? + /// - Parameter service: The service requesting the key. + /// - Returns: The encryption key or `nil`. + /// - Throws: An error if the key cannot be retrieved. + func databaseService(keyFor service: DatabaseServiceProtocol) throws -> Connection.Key? - /// Notifies the provider that the database service encountered an error - /// related to key retrieval or application. + /// Indicates whether the service should attempt to reconnect if applying the key fails. /// - /// This method is called **only** when the service fails to retrieve or apply an - /// encryption key (e.g., if ``databaseServiceKey(_:)`` throws, or if the key fails - /// to decrypt the database due to a password/key mismatch). - /// - /// Use this callback to report diagnostics, trigger recovery logic, prompt the user - /// for authentication, or update internal state. - /// By default, this method does nothing; implement it only if you need to respond - /// to key-related failures. + /// - Parameter service: The database service. + /// - Returns: `true` to attempt reconnection. Defaults to `false`. + func databaseService(shouldReconnect service: DatabaseServiceProtocol) -> Bool + + /// Notifies the provider of an error that occurred while retrieving or applying the key. /// /// - Parameters: /// - service: The database service reporting the error. /// - error: The error encountered during key retrieval or application. - func databaseService(_ service: DatabaseService, didReceive error: Error) - - /// Informs the service whether it should attempt to reconnect automatically. - /// - /// Return `true` if the service should retry connecting (for example, if the key may - /// become available shortly). By default, returns `false`. - /// - /// - Parameter service: The database service. - /// - Returns: `true` to retry, `false` to abort. - func databaseServiceShouldReconnect(_ service: DatabaseService) -> Bool + func databaseService(_ service: DatabaseServiceProtocol, didReceive error: Error) } public extension DatabaseServiceKeyProvider { - /// Default no-op implementation for key-related error reporting. - func databaseService(_ service: DatabaseService, didReceive error: Error) {} - - /// Default implementation disables automatic reconnect attempts. - func databaseServiceShouldReconnect(_ service: DatabaseService) -> Bool { - false - } + func databaseService(shouldReconnect service: DatabaseServiceProtocol) -> Bool { false } + func databaseService(_ service: DatabaseServiceProtocol, didReceive error: Error) {} } diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift index 0b3face..efe633b 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -1,14 +1,15 @@ import Foundation import DataLiteCore -/// A protocol that defines a common interface for working with a database connection. +/// A protocol for a database service. /// -/// `DatabaseServiceProtocol` abstracts the core operations required to safely interact with a -/// SQLite-compatible database. Conforming types provide thread-safe execution of closures with a live -/// `Connection`, optional transaction support, reconnection logic, and pluggable encryption key -/// management via a ``DatabaseServiceKeyProvider``. +/// `DatabaseServiceProtocol` defines the core capabilities required for +/// reliable interaction with a database. Conforming implementations provide +/// execution of client closures with a live connection, transaction wrapping, +/// reconnection logic, and flexible encryption key management. /// -/// This protocol forms the foundation for safe, modular service layers on top of a database. +/// This enables building safe and extensible service layers on top of +/// a database. /// /// ## Topics /// @@ -16,6 +17,7 @@ import DataLiteCore /// /// - ``DatabaseServiceKeyProvider`` /// - ``keyProvider`` +/// - ``applyKeyProvider()`` /// /// ### Connection Management /// @@ -26,63 +28,63 @@ import DataLiteCore /// - ``Perform`` /// - ``perform(_:)`` /// - ``perform(in:closure:)`` -public protocol DatabaseServiceProtocol: AnyObject { - /// A closure that performs a database operation using an active connection. +public protocol DatabaseServiceProtocol: AnyObject, Sendable { + /// A closure executed with an active database connection. /// - /// The `Perform` type alias defines a closure signature for a database operation that - /// receives a live `Connection` and returns a value or throws an error. This enables - /// callers to express discrete, atomic database operations for execution via - /// ``perform(_:)`` or ``perform(in:closure:)``. + /// Used by the service to safely provide access to `Connection` + /// within the appropriate execution context. /// /// - Parameter connection: The active database connection. - /// - Returns: The result of the operation. - /// - Throws: Any error thrown during execution of the operation. + /// - Returns: The value returned by the closure. + /// - Throws: An error if the closure execution fails. typealias Perform = (Connection) throws -> T - /// The object responsible for providing encryption keys for the database connection. + /// The encryption key provider for the database service. /// - /// When assigned, the key provider will be queried for a key and applied to the current - /// connection, if available. If key retrieval or application fails, the error is reported - /// via `databaseService(_:didReceive:)` and not thrown from the setter. - /// - /// - Important: Setting this property does not guarantee that the connection becomes available; - /// error handling is asynchronous via callback. + /// Enables external management of encryption keys. + /// When set, the service can request a key when establishing or + /// restoring a connection, and can also notify about errors + /// encountered while applying a key. var keyProvider: DatabaseServiceKeyProvider? { get set } - /// Re-establishes the database connection using the stored provider. + /// Applies the encryption key from the current provider. /// - /// If a `keyProvider` is set, the method attempts to retrieve and apply a key - /// to the new connection. All errors encountered during connection creation or - /// key application are thrown. If an error occurs that is related to encryption key - /// retrieval or application, it is also reported to the `DatabaseServiceKeyProvider` - /// via its `databaseService(_:didReceive:)` callback. + /// Calls the configured ``keyProvider`` to obtain a key and applies + /// it to the active connection. If the key is unavailable or an + /// error occurs while applying it, the method throws. /// - /// - Throws: Any error that occurs during connection creation or key application. + /// - Throws: An error if the key cannot be retrieved or applied. + func applyKeyProvider() throws + + /// Reopens the database connection. + /// + /// Creates a new connection using the provider and applies the + /// encryption key if ``keyProvider`` is set. Typically used when + /// the previous connection has become invalid. + /// + /// - Throws: An error if the new connection cannot be created or the key cannot be applied. func reconnect() throws - /// Executes the given closure with a live connection in a thread-safe manner. + /// Executes the given closure with an active connection. /// - /// All invocations are serialized to prevent concurrent database access. + /// The closure receives the connection and may perform any + /// database operations within the current context. /// - /// - Parameter closure: The database operation to perform. - /// - Returns: The result produced by the closure. - /// - Throws: Any error thrown by the closure. + /// - Parameter closure: The closure that accepts a connection. + /// - Returns: The value returned by the closure. + /// - Throws: An error if one occurs during closure execution. func perform(_ closure: Perform) rethrows -> T /// Executes the given closure within a transaction. /// - /// If no transaction is active, a new transaction of the specified type is started. The closure - /// is executed atomically: if it succeeds, the transaction is committed; if it throws, the - /// transaction is rolled back. If a transaction is already active, the closure is executed - /// without starting a new one. + /// If the connection is in autocommit mode, the method automatically + /// begins a transaction, executes the closure, and commits the changes. + /// In case of failure, the transaction is rolled back. /// /// - Parameters: - /// - transaction: The type of transaction to begin (e.g., `deferred`, `immediate`, `exclusive`). - /// - closure: The database operation to perform within the transaction. - /// - Returns: The result produced by the closure. - /// - Throws: Any error thrown by the closure or transaction control statements. - func perform( - in transaction: TransactionType, - closure: Perform - ) rethrows -> T + /// - transaction: The type of transaction to begin. + /// - closure: The closure that accepts a connection. + /// - Returns: The value returned by the closure. + /// - Throws: An error if one occurs during closure execution. + func perform(in transaction: TransactionType, closure: Perform) rethrows -> T } diff --git a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift index 0f2bc00..88973d9 100644 --- a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -1,21 +1,38 @@ import Foundation -/// Protocol for managing and running database schema migrations. -public protocol MigrationServiceProtocol: AnyObject { - /// Type representing the schema version for migrations. +/// Protocol for managing and executing database schema migrations. +/// +/// Conforming types are responsible for registering migrations, applying +/// encryption keys (if required), and executing pending migrations in +/// ascending version order. +/// +/// Migrations ensure that the database schema evolves consistently across +/// application versions without requiring manual intervention. +public protocol MigrationServiceProtocol: AnyObject, Sendable { + /// Type representing the schema version used for migrations. associatedtype Version: VersionRepresentable - /// Provider of encryption keys for the database service. + /// Encryption key provider for the database service. var keyProvider: DatabaseServiceKeyProvider? { get set } - /// Adds a migration to be executed by the service. + /// Applies an encryption key to the current database connection. + /// + /// - Throws: Any error that occurs while retrieving or applying the key. + func applyKeyProvider() throws + + /// Recreates the database connection and reapplies the encryption key if available. + /// + /// - Throws: Any error that occurs while creating the connection or applying the key. + func reconnect() throws + + /// Registers a migration to be executed by the service. /// /// - Parameter migration: The migration to register. /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with /// the same version or script URL is already registered. func add(_ migration: Migration) throws(MigrationError) - /// Runs all pending migrations in ascending version order. + /// Executes all pending migrations in ascending version order. /// /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration /// script is empty. @@ -26,10 +43,11 @@ public protocol MigrationServiceProtocol: AnyObject { @available(iOS 13.0, *) @available(macOS 10.15, *) -public extension MigrationServiceProtocol where Self: Sendable { - /// Asynchronously runs all pending migrations in ascending order. +public extension MigrationServiceProtocol { + /// Asynchronously executes all pending migrations in ascending order. /// - /// Performs the same logic as ``migrate()``, but runs asynchronously. + /// Performs the same logic as ``migrate()``, but runs asynchronously + /// on a background task with `.utility` priority. /// /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration /// script is empty. diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift index 5af6b41..9f05454 100644 --- a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -4,7 +4,7 @@ import DataLiteC import DataLiteCore import DataRaft -class DatabaseServiceTests: DatabaseServiceKeyProvider { +class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { private let keyOne = Connection.Key.rawKey(Data([ 0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0, 0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a, @@ -40,6 +40,7 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider { self.service = service self.service.keyProvider = self + try self.service.applyKeyProvider() try self.service.perform { connection in try connection.execute(sql: """ CREATE TABLE IF NOT EXISTS Item ( @@ -54,11 +55,11 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider { try? FileManager.default.removeItem(at: fileURL) } - func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key? { + func databaseService(keyFor service: any DatabaseServiceProtocol) throws -> Connection.Key? { currentKey } - func databaseServiceShouldReconnect(_ service: DatabaseService) -> Bool { + func databaseService(shouldReconnect service: any DatabaseServiceProtocol) -> Bool { true } } diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift index d4095a2..f116473 100644 --- a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -12,7 +12,7 @@ import DataLiteCore init() throws { let connection = try Connection(location: .inMemory, options: .readwrite) self.connection = connection - self.migrationService = .init(service: .init(connection: connection), storage: .init()) + self.migrationService = .init(service: try .init(connection: connection), storage: .init()) } @Test func addMigration() throws {