From 860f73b731d28cab569931e6097127772aef6c58 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Thu, 7 Aug 2025 23:50:00 +0300 Subject: [PATCH] Add protocol for migration service --- .../DataRaft/Classes/DatabaseService.swift | 245 +++++++++++------- .../DataRaft/Classes/MigrationService.swift | 121 ++++----- .../DataRaft/Classes/RowDatabaseService.swift | 6 +- .../DataRaft/Classes/UserVersionStorage.swift | 2 +- Sources/DataRaft/Enums/MigrationError.swift | 14 + .../DatabaseServiceKeyProvider.swift | 127 +++++++-- .../Protocols/DatabaseServiceProtocol.swift | 73 ++++-- .../Protocols/MigrationServiceProtocol.swift | 43 +++ .../Classes/DatabaseServiceTests.swift | 6 +- .../Classes/MigrationServiceTests.swift | 19 +- 10 files changed, 448 insertions(+), 208 deletions(-) create mode 100644 Sources/DataRaft/Enums/MigrationError.swift create mode 100644 Sources/DataRaft/Protocols/MigrationServiceProtocol.swift diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 5c80e2d..1e81c64 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -48,7 +48,69 @@ import DataLiteC /// /// This approach allows you to build reusable service layers on top of a safe, transactional, /// and serialized foundation. -open class DatabaseService: DatabaseServiceProtocol { +/// +/// ## 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. +/// +/// ## 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. +/// +/// ## Reconnect Behavior +/// +/// 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. +/// +/// ## Topics +/// +/// ### Initializers +/// +/// - ``init(provider:queue:)`` +/// - ``init(connection:queue:)`` +/// +/// ### Key Management +/// +/// - ``DatabaseServiceKeyProvider`` +/// - ``keyProvider`` +/// +/// ### Connection Management +/// +/// - ``ConnectionProvider`` +/// - ``reconnect()`` +/// +/// ### Database Operations +/// +/// - ``DatabaseServiceProtocol/Perform`` +/// - ``perform(_:)`` +/// - ``perform(in:closure:)`` +open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { /// A closure that provides a new database connection when invoked. /// /// `ConnectionProvider` is used to defer the creation of a `Connection` instance @@ -62,58 +124,33 @@ open class DatabaseService: DatabaseServiceProtocol { // MARK: - Properties private let provider: ConnectionProvider - private var connection: Connection private let queue: DispatchQueue private let queueKey = DispatchSpecificKey() + private var connection: Connection - /// The object that provides the encryption key for the database connection. + /// Provides the encryption key for the database connection. /// - /// When this property is set, the service attempts to retrieve an encryption key from the - /// provider and apply it to the current database connection. This operation is performed - /// synchronously on the service’s internal queue to ensure thread safety. + /// 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, the service notifies the provider - /// by calling `databaseService(_:didReceive:)`. + /// 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 enables external management of encryption keys, including features such as key rotation, - /// user-scoped encryption, or error handling delegation. - /// - /// - Important: The service does not retry failed key applications. Ensure the provider is - /// correctly configured and able to supply a valid key when needed. + /// 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 { - perform { connection in - do { - if let key = try keyProvider?.databaseServiceKey(self) { - try connection.apply(key) - } - } catch { - keyProvider?.databaseService(self, didReceive: error) - } + withConnection { connection in + try? applyKey(to: connection) } } } // MARK: - Inits - /// 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. - /// - /// - 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. - public convenience init( - connection provider: @escaping @autoclosure ConnectionProvider, - queue: DispatchQueue? = nil - ) rethrows { - try self.init(provider: provider, queue: queue) - } - /// Creates a new `DatabaseService` with the specified connection provider and dispatch queue. /// /// This initializer immediately invokes the `provider` closure to establish the initial database @@ -139,85 +176,88 @@ open class DatabaseService: DatabaseServiceProtocol { } } + /// 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. + /// + /// - 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. + public convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + queue: DispatchQueue? = nil + ) rethrows { + try self.init(provider: provider, queue: queue) + } + // MARK: - Methods /// Re-establishes the database connection using the stored connection provider. /// - /// This method creates a new `Connection` instance by invoking the original provider. If a - /// `keyProvider` is set, the method attempts to retrieve and apply an encryption key to the new - /// connection. The new connection replaces the existing one. + /// 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 operation is executed synchronously on the internal dispatch queue via `perform(_:)` + /// The new connection replaces the existing one only if all steps succeed without errors. + /// + /// This operation is always executed on the internal dispatch queue (see ``perform(_:)``) /// to ensure thread safety. /// - /// - Throws: Any error thrown during connection creation or while retrieving or applying the - /// encryption key. + /// - 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 { - try perform { _ in + try withConnection { _ in let connection = try provider() - if let key = try keyProvider?.databaseServiceKey(self) { - try connection.apply(key) - } + try applyKey(to: connection) self.connection = connection } } /// Executes the given closure using the active database connection. /// - /// This method ensures thread-safe access to the underlying `Connection` by synchronizing - /// execution on an internal serial dispatch queue. If the call is already on that queue, the - /// closure is executed directly to avoid unnecessary dispatching. - /// - /// If the closure throws a `SQLiteError` with code `SQLITE_NOTADB` (e.g., when the database file - /// is corrupted or invalid), the service attempts to re-establish the connection by calling - /// ``reconnect()``. The error is still rethrown after reconnection. + /// 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. /// /// - Parameter 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 or during reconnection logic. + /// - Throws: Any error thrown by the closure. public func perform(_ closure: Perform) rethrows -> T { - do { - switch DispatchQueue.getSpecific(key: queueKey) { - case .none: return try queue.asyncAndWait { try closure(connection) } - case .some: return try closure(connection) - } - } catch { - switch error { - case let error as Connection.Error: - if error.code == SQLITE_NOTADB { - try reconnect() - } - fallthrough - default: - throw error - } - } + try withConnection(closure) } /// Executes a closure inside a transaction if the connection is in autocommit mode. /// - /// If the current connection is in autocommit mode, a new transaction of the specified type - /// is started, and the closure is executed within it. If the closure completes successfully, - /// the transaction is committed. If an error is thrown, the transaction is rolled back. + /// 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 thrown error is a `SQLiteError` with code `SQLITE_NOTADB`, the service attempts to - /// reconnect and retries the entire transaction block exactly once. + /// 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 connection is already within a transaction (i.e., not in autocommit mode), - /// the closure is executed directly without starting a new transaction. + /// If already inside a transaction (not in autocommit mode), executes the closure directly + /// without starting a new transaction. /// /// - Parameters: - /// - transaction: The type of transaction to begin (e.g., `deferred`, `immediate`, `exclusive`). + /// - transaction: The type of transaction to begin. /// - 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 control statements, or reconnect logic. + /// + /// - Important: The closure may be executed more than once. Ensure it is idempotent. public func perform( in transaction: TransactionType, closure: Perform ) rethrows -> T { - if connection.isAutocommit { - try perform { connection in + try withConnection { connection in + if connection.isAutocommit { do { try connection.beginTransaction(transaction) let result = try closure(connection) @@ -226,12 +266,13 @@ open class DatabaseService: DatabaseServiceProtocol { } catch { try connection.rollbackTransaction() guard let error = error as? Connection.Error, - error.code == SQLITE_NOTADB + error.code == SQLITE_NOTADB, + shouldReconnect else { throw error } try reconnect() - return try perform { connection in + return try withConnection { connection in do { try connection.beginTransaction(transaction) let result = try closure(connection) @@ -243,9 +284,35 @@ open class DatabaseService: DatabaseServiceProtocol { } } } + } else { + return try closure(connection) } - } else { - try perform(closure) + } + } +} + +private extension DatabaseService { + var shouldReconnect: Bool { + keyProvider?.databaseServiceShouldReconnect(self) ?? false + } + + func withConnection(_ closure: Perform) rethrows -> T { + switch DispatchQueue.getSpecific(key: queueKey) { + case .none: try queue.asyncAndWait { try closure(connection) } + case .some: try closure(connection) + } + } + + func applyKey(to connection: Connection) throws { + do { + if let key = try keyProvider?.databaseServiceKey(self) { + let sql = "SELECT count(*) FROM sqlite_master" + try connection.apply(key) + try connection.execute(raw: sql) + } + } catch { + keyProvider?.databaseService(self, didReceive: error) + throw error } } } diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift index c3324a2..5c564da 100644 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -1,110 +1,113 @@ import Foundation import DataLiteCore -/// A service responsible for managing and applying database migrations in a versioned manner. +/// Thread-safe service for executing ordered database schema migrations. /// -/// `MigrationService` manages a collection of migrations identified by versions and script URLs, -/// and applies them sequentially to update the database schema. It ensures that each migration -/// is applied only once, and in the correct version order based on the current database version. +/// `MigrationService` stores registered migrations and applies them sequentially +/// to update the database schema. Each migration runs only once, in version order, +/// based on the current schema version stored in the database. /// -/// This service is generic over a `VersionStorage` implementation that handles storing and -/// retrieving the current database version. Migrations must have unique versions and script URLs -/// to prevent duplication. +/// The service is generic over: +/// - `Service`: a database service conforming to ``DatabaseServiceProtocol`` +/// - `Storage`: a version storage conforming to ``VersionStorage`` +/// +/// Migrations are identified by version and script URL. Both must be unique +/// across all registered migrations. +/// +/// Execution is performed inside a single `.exclusive` transaction, ensuring +/// that either all pending migrations are applied successfully or none are. +/// On error, the database state is rolled back to the original version. +/// +/// This type is safe to use from multiple threads. /// /// ```swift /// let connection = try Connection(location: .inMemory, options: .readwrite) /// let storage = UserVersionStorage() -/// let service = MigrationService(storage: storage, connection: connection) +/// let service = MigrationService(service: connectionService, storage: storage) /// /// try service.add(Migration(version: "1.0.0", byResource: "v_1_0_0.sql")!) /// try service.add(Migration(version: "1.0.1", byResource: "v_1_0_1.sql")!) -/// try service.add(Migration(version: "1.1.0", byResource: "v_1_1_0.sql")!) -/// try service.add(Migration(version: "1.2.0", byResource: "v_1_2_0.sql")!) -/// /// try service.migrate() /// ``` /// /// ### Custom Versions and Storage /// -/// You can customize versioning by providing your own `Version` type conforming to -/// ``VersionRepresentable``, which supports comparison, hashing, and identity checks. -/// -/// The storage backend (`VersionStorage`) defines how the version is persisted, such as -/// in a pragma, table, or metadata. -/// -/// This allows using semantic versions, integers, or other schemes, and storing them -/// in custom places. -public final class MigrationService { - /// The version type used by this migration service, derived from the storage type. +/// You can supply a custom `Version` type conforming to ``VersionRepresentable`` +/// and a `VersionStorage` implementation that determines how and where the +/// version is persisted (e.g., `PRAGMA user_version`, metadata table, etc.). +public final class MigrationService< + Service: DatabaseServiceProtocol, + Storage: VersionStorage +>: + MigrationServiceProtocol, + @unchecked Sendable +{ + /// Schema version type used for migration ordering. public typealias Version = Storage.Version - /// Errors that may occur during migration registration or execution. - public enum Error: Swift.Error { - /// A migration with the same version or script URL was already registered. - case duplicateMigration(Migration) - - /// Migration execution failed, with optional reference to the failed migration. - case migrationFailed(Migration?, Swift.Error) - - /// The migration script is empty. - case emptyMigrationScript(Migration) - } - - // MARK: - Properties - private let service: Service private let storage: Storage + private var mutex = pthread_mutex_t() private var migrations = Set>() - /// The encryption key provider delegated to the underlying database service. + /// Encryption key provider delegated to the underlying database service. public weak var keyProvider: DatabaseServiceKeyProvider? { get { service.keyProvider } set { service.keyProvider = newValue } } - // MARK: - Inits - - /// Creates a new migration service with the given database service and version storage. + /// Creates a migration service with the given database service and storage. /// /// - Parameters: - /// - service: The database service used to perform migrations. - /// - storage: The version storage implementation used to track the current schema version. + /// - service: Database service used to execute migrations. + /// - storage: Version storage for reading and writing schema version. public init( service: Service, storage: Storage ) { self.service = service self.storage = storage + pthread_mutex_init(&mutex, nil) } - // MARK: - Migration Management + deinit { + pthread_mutex_destroy(&mutex) + } - /// Registers a new migration. - /// - /// Ensures that no other migration with the same version or script URL has been registered. + /// Registers a new migration, ensuring version and script URL uniqueness. /// /// - Parameter migration: The migration to register. - /// - Throws: ``Error/duplicateMigration(_:)`` if the migration version or script URL duplicates an existing one. - public func add(_ migration: Migration) throws { + /// - Throws: ``MigrationError/duplicateMigration(_:)`` if the migration's + /// version or script URL is already registered. + public func add(_ migration: Migration) throws(MigrationError) { + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } guard !migrations.contains(where: { $0.version == migration.version || $0.scriptURL == migration.scriptURL }) else { - throw Error.duplicateMigration(migration) + throw .duplicateMigration(migration) } migrations.insert(migration) } - /// Executes all pending migrations in ascending version order. + /// Executes all pending migrations inside a single exclusive transaction. /// - /// This method retrieves the current schema version from the storage, filters and sorts - /// pending migrations, executes each migration script within a single exclusive transaction, - /// and updates the schema version on success. + /// This method retrieves the current schema version from storage, then determines + /// which migrations have a higher version. The selected migrations are sorted in + /// ascending order and each one's SQL script is executed in sequence. When all + /// scripts complete successfully, the stored version is updated to the highest + /// applied migration. /// - /// If a migration script is empty or a migration fails, the process aborts and rolls back changes. + /// If a script is empty or execution fails, the process aborts and the transaction + /// is rolled back, leaving the database unchanged. /// - /// - Throws: ``Error/migrationFailed(_:_:)`` if a migration script fails or if updating the version fails. - public func migrate() throws { + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if execution or version + /// update fails. + public func migrate() throws(MigrationError) { + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } do { try service.perform(in: .exclusive) { connection in try storage.prepare(connection) @@ -116,12 +119,12 @@ public final class MigrationService { throw error } catch { - throw Error.migrationFailed(nil, error) + throw .migrationFailed(nil, error) } } } diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift index 079d382..993bea8 100644 --- a/Sources/DataRaft/Classes/RowDatabaseService.swift +++ b/Sources/DataRaft/Classes/RowDatabaseService.swift @@ -44,7 +44,11 @@ import DataLiteCoder /// `RowDatabaseService` encourages a reusable, type-safe pattern for /// model-based interaction with SQLite while preserving thread safety /// and transactional integrity. -open class RowDatabaseService: DatabaseService, RowDatabaseServiceProtocol { +open class RowDatabaseService: + DatabaseService, + RowDatabaseServiceProtocol, + @unchecked Sendable +{ // MARK: - Properties /// The encoder used to serialize values into row representations. diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift index 1da6303..1fdad7c 100644 --- a/Sources/DataRaft/Classes/UserVersionStorage.swift +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -13,7 +13,7 @@ import DataLiteCore /// defined by the application. public final class UserVersionStorage< Version: VersionRepresentable & RawRepresentable ->: VersionStorage where Version.RawValue == UInt32 { +>: Sendable, VersionStorage where Version.RawValue == UInt32 { /// Errors related to reading or decoding the version. public enum Error: Swift.Error { /// The stored `user_version` could not be decoded into a valid `Version` case. diff --git a/Sources/DataRaft/Enums/MigrationError.swift b/Sources/DataRaft/Enums/MigrationError.swift new file mode 100644 index 0000000..6fcad25 --- /dev/null +++ b/Sources/DataRaft/Enums/MigrationError.swift @@ -0,0 +1,14 @@ +import Foundation +import DataLiteCore + +/// Errors that may occur during migration registration or execution. +public enum MigrationError: Error { + /// A migration with the same version or script URL was already registered. + case duplicateMigration(Migration) + + /// Migration execution failed, with optional reference to the failed migration. + case migrationFailed(Migration?, Error) + + /// The migration script is empty. + case emptyMigrationScript(Migration) +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift index 840d4b5..00d75d2 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift @@ -3,51 +3,122 @@ import DataLiteCore /// A protocol for supplying encryption keys to `DatabaseService` instances. /// -/// `DatabaseServiceKeyProvider` allows database services to delegate the responsibility of -/// retrieving, managing, and applying encryption keys. This enables separation of concerns -/// and allows for advanced strategies such as per-user key derivation, secure hardware-backed -/// storage, or biometric access control. +/// `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. /// -/// When assigned to a `DatabaseService`, the provider is queried automatically whenever a -/// connection is created or re-established (e.g., during service initialization or reconnect). +/// 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). /// -/// You can also implement error handling or diagnostics via the optional -/// ``databaseService(_:didReceive:)`` method. +/// 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. /// -/// - Tip: You may throw from ``databaseServiceKey(_:)`` to indicate that the key is temporarily -/// unavailable or access is denied. +/// - 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. +/// +/// ## Key Availability +/// +/// 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. /// - /// This method is invoked by the `DatabaseService` during initialization or reconnection - /// to retrieve the encryption key that should be applied to the new connection. - /// - /// Implementations may return a static key, derive it from metadata, or load it from - /// secure storage. If the key is unavailable (e.g., user not authenticated, system locked), - /// this method may throw to indicate failure. + /// 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. /// /// - Parameter service: The requesting database service. - /// - Returns: A `Connection.Key` representing the encryption key. - /// - Throws: Any error indicating that the key cannot be retrieved. - func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key - - /// Notifies the provider that the database service encountered an error while applying a key. + /// - 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. /// - /// This method is called when the service fails to retrieve or apply the encryption key. - /// You can use it to report diagnostics, attempt recovery, or update internal state. + /// - 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? + + /// Notifies the provider that the database service encountered an error + /// related to key retrieval or application. /// - /// The default implementation is a no-op. + /// 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. /// /// - 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 } public extension DatabaseServiceKeyProvider { - /// Default no-op implementation of error handling callback. - /// - /// This allows conforming types to ignore the error reporting mechanism - /// if they do not need to respond to key failures. + /// 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 + } } diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift index ddd73a4..0b3face 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -3,51 +3,84 @@ import DataLiteCore /// A protocol that defines a common interface for working with a database connection. /// -/// Conforming types provide methods for executing closures with a live `Connection`, optionally -/// wrapped in transactions. These closures are guaranteed to execute in a thread-safe and -/// serialized manner. Implementations may also support reconnecting and managing encryption keys. +/// `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``. +/// +/// This protocol forms the foundation for safe, modular service layers on top of a database. +/// +/// ## Topics +/// +/// ### Key Management +/// +/// - ``DatabaseServiceKeyProvider`` +/// - ``keyProvider`` +/// +/// ### Connection Management +/// +/// - ``reconnect()`` +/// +/// ### Database Operations +/// +/// - ``Perform`` +/// - ``perform(_:)`` +/// - ``perform(in:closure:)`` public protocol DatabaseServiceProtocol: AnyObject { /// A closure that performs a database operation using an active connection. /// - /// The `Perform` alias defines the signature for a database operation block - /// that receives a live `Connection` and either returns a result or throws an error. - /// It is commonly used to express atomic units of work in ``perform(_:)`` or - /// ``perform(in:closure:)`` calls. + /// 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:)``. /// - /// - Parameter T: The result type returned by the closure. - /// - Returns: A value of type `T` produced by the closure. - /// - Throws: Any error that occurs during execution of the database operation. + /// - Parameter connection: The active database connection. + /// - Returns: The result of the operation. + /// - Throws: Any error thrown during execution of the operation. typealias Perform = (Connection) throws -> T /// The object responsible for providing encryption keys for the database connection. /// - /// When assigned, the key provider will be queried for a new key and applied to the current - /// connection, if available. + /// 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. var keyProvider: DatabaseServiceKeyProvider? { get set } /// Re-establishes the database connection using the stored provider. /// - /// If a `keyProvider` is set, the returned connection will attempt to apply a new key. + /// 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. /// /// - Throws: Any error that occurs during connection creation or key application. func reconnect() throws - /// Executes the given closure with a live connection. + /// Executes the given closure with a live connection in a thread-safe manner. /// - /// - Parameter closure: The operation to execute. + /// All invocations are serialized to prevent concurrent database access. + /// + /// - Parameter closure: The database operation to perform. /// - Returns: The result produced by the closure. - /// - Throws: Any error thrown during execution. + /// - Throws: Any error thrown by the closure. func perform(_ closure: Perform) rethrows -> T /// Executes the given closure within a transaction. /// - /// If no transaction is active, a new one is started and committed or rolled back as needed. + /// 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. /// /// - Parameters: - /// - transaction: The transaction type to begin. - /// - closure: The operation to execute within the transaction. + /// - 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. + /// - Throws: Any error thrown by the closure or transaction control statements. func perform( in transaction: TransactionType, closure: Perform diff --git a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift new file mode 100644 index 0000000..0f2bc00 --- /dev/null +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Protocol for managing and running database schema migrations. +public protocol MigrationServiceProtocol: AnyObject { + /// Type representing the schema version for migrations. + associatedtype Version: VersionRepresentable + + /// Provider of encryption keys for the database service. + var keyProvider: DatabaseServiceKeyProvider? { get set } + + /// Adds 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. + /// + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration + /// script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution + /// or version update fails. + func migrate() throws(MigrationError) +} + +@available(iOS 13.0, *) +@available(macOS 10.15, *) +public extension MigrationServiceProtocol where Self: Sendable { + /// Asynchronously runs all pending migrations in ascending order. + /// + /// Performs the same logic as ``migrate()``, but runs asynchronously. + /// + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration + /// script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution + /// or version update fails. + func migrate() async throws { + try await Task(priority: .utility) { + try self.migrate() + }.value + } +} diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift index ce04436..5af6b41 100644 --- a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -54,9 +54,13 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider { try? FileManager.default.removeItem(at: fileURL) } - func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key { + func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key? { currentKey } + + func databaseServiceShouldReconnect(_ service: DatabaseService) -> Bool { + true + } } extension DatabaseServiceTests { diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift index 9f145e6..d4095a2 100644 --- a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -4,6 +4,7 @@ import DataLiteCore @Suite struct MigrationServiceTests { private typealias MigrationService = DataRaft.MigrationService + private typealias MigrationError = DataRaft.MigrationError private var connection: Connection! private var migrationService: MigrationService! @@ -25,25 +26,25 @@ import DataLiteCore do { try migrationService.add(migration3) Issue.record("Expected duplicateMigration error for version \(migration3.version)") - } catch MigrationService.Error.duplicateMigration(let migration) { + } catch MigrationError.duplicateMigration(let migration) { #expect(migration == migration3) } catch { Issue.record("Unexpected error: \(error)") } } - @Test func migrate() throws { + @Test func migrate() async throws { let migration1 = Migration(version: 1, byResource: "migration_1", extension: "sql", in: .module)! let migration2 = Migration(version: 2, byResource: "migration_2", extension: "sql", in: .module)! try migrationService.add(migration1) try migrationService.add(migration2) - try migrationService.migrate() + try await migrationService.migrate() #expect(connection.userVersion == 2) } - @Test func migrateWithError() throws { + @Test func migrateError() async throws { let migration1 = Migration(version: 1, byResource: "migration_1", extension: "sql", in: .module)! let migration2 = Migration(version: 2, byResource: "migration_2", extension: "sql", in: .module)! let migration3 = Migration(version: 3, byResource: "migration_3", extension: "sql", in: .module)! @@ -53,9 +54,9 @@ import DataLiteCore try migrationService.add(migration3) do { - try migrationService.migrate() + try await migrationService.migrate() Issue.record("Expected migrationFailed error for version \(migration3.version)") - } catch MigrationService.Error.migrationFailed(let migration, _) { + } catch MigrationError.migrationFailed(let migration, _) { #expect(migration == migration3) } catch { Issue.record("Unexpected error: \(error)") @@ -64,7 +65,7 @@ import DataLiteCore #expect(connection.userVersion == 0) } - @Test func migrateWithEmptyMigration() throws { + @Test func migrateEmpty() async throws { let migration1 = Migration(version: 1, byResource: "migration_1", extension: "sql", in: .module)! let migration2 = Migration(version: 2, byResource: "migration_2", extension: "sql", in: .module)! let migration4 = Migration(version: 4, byResource: "migration_4", extension: "sql", in: .module)! @@ -74,9 +75,9 @@ import DataLiteCore try migrationService.add(migration4) do { - try migrationService.migrate() + try await migrationService.migrate() Issue.record("Expected migrationFailed error for version \(migration4.version)") - } catch MigrationService.Error.emptyMigrationScript(let migration) { + } catch MigrationError.emptyMigrationScript(let migration) { #expect(migration == migration4) } catch { Issue.record("Unexpected error: \(error)")