From 1b2cdaf23edd4970f89dc1f14eca33049c03f822 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Sun, 18 May 2025 17:47:22 +0300 Subject: [PATCH 1/7] DataRaft swift package --- Package.swift | 48 ++++ README.md | 62 +++++ .../DataRaft/Classes/DatabaseService.swift | 251 ++++++++++++++++++ .../DataRaft/Classes/MigrationService.swift | 138 ++++++++++ .../DataRaft/Classes/RowDatabaseService.swift | 111 ++++++++ .../DataRaft/Classes/UserVersionStorage.swift | 66 +++++ .../DataRaft/Extensions/DispatchQueue.swift | 19 ++ .../DatabaseServiceKeyProvider.swift | 53 ++++ .../Protocols/DatabaseServiceProtocol.swift | 55 ++++ .../RowDatabaseServiceProtocol.swift | 17 ++ .../Protocols/VersionRepresentable.swift | 33 +++ .../DataRaft/Protocols/VersionStorage.swift | 140 ++++++++++ .../DataRaft/Structures/BitPackVersion.swift | 158 +++++++++++ Sources/DataRaft/Structures/Migration.swift | 75 ++++++ .../Classes/DatabaseServiceTests.swift | 194 ++++++++++++++ .../Classes/MigrationServiceTests.swift | 101 +++++++ .../Classes/UserVersionStorage.swift | 64 +++++ Tests/DataRaftTests/Resources/migration_1.sql | 12 + Tests/DataRaftTests/Resources/migration_2.sql | 11 + Tests/DataRaftTests/Resources/migration_3.sql | 2 + Tests/DataRaftTests/Resources/migration_4.sql | 1 + .../Structures/BitPackVersionTests.swift | 182 +++++++++++++ .../Structures/MigrationTests.swift | 69 +++++ 23 files changed, 1862 insertions(+) create mode 100644 Package.swift create mode 100644 Sources/DataRaft/Classes/DatabaseService.swift create mode 100644 Sources/DataRaft/Classes/MigrationService.swift create mode 100644 Sources/DataRaft/Classes/RowDatabaseService.swift create mode 100644 Sources/DataRaft/Classes/UserVersionStorage.swift create mode 100644 Sources/DataRaft/Extensions/DispatchQueue.swift create mode 100644 Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift create mode 100644 Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift create mode 100644 Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift create mode 100644 Sources/DataRaft/Protocols/VersionRepresentable.swift create mode 100644 Sources/DataRaft/Protocols/VersionStorage.swift create mode 100644 Sources/DataRaft/Structures/BitPackVersion.swift create mode 100644 Sources/DataRaft/Structures/Migration.swift create mode 100644 Tests/DataRaftTests/Classes/DatabaseServiceTests.swift create mode 100644 Tests/DataRaftTests/Classes/MigrationServiceTests.swift create mode 100644 Tests/DataRaftTests/Classes/UserVersionStorage.swift create mode 100644 Tests/DataRaftTests/Resources/migration_1.sql create mode 100644 Tests/DataRaftTests/Resources/migration_2.sql create mode 100644 Tests/DataRaftTests/Resources/migration_3.sql create mode 100644 Tests/DataRaftTests/Resources/migration_4.sql create mode 100644 Tests/DataRaftTests/Structures/BitPackVersionTests.swift create mode 100644 Tests/DataRaftTests/Structures/MigrationTests.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8ee30ed --- /dev/null +++ b/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "DataRaft", + platforms: [ + .macOS(.v10_14), + .iOS(.v12) + ], + products: [ + .library( + name: "DataRaft", + targets: ["DataRaft"] + ) + ], + dependencies: [ + .package( + url: "https://github.com/angd-dev/data-lite-core.git", + revision: "5c6942bd0b9636b5ac3e550453c07aac843e8416" + ), + .package( + url: "https://github.com/angd-dev/data-lite-coder.git", + revision: "5aec6ea5784dd5bd098bfa98036fbdc362a8931c" + ), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + ], + targets: [ + .target( + name: "DataRaft", + dependencies: [ + .product(name: "DataLiteCore", package: "data-lite-core"), + .product(name: "DataLiteCoder", package: "data-lite-coder") + ] + ), + .testTarget( + name: "DataRaftTests", + dependencies: ["DataRaft"], + resources: [ + .copy("Resources/migration_1.sql"), + .copy("Resources/migration_2.sql"), + .copy("Resources/migration_3.sql"), + .copy("Resources/migration_4.sql") + ] + ) + ] +) diff --git a/README.md b/README.md index e69de29..11ddbce 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,62 @@ +# DataRaft + +DataRaft is a minimalistic Swift library for safe, predictable, and concurrent SQLite access. + +## Overview + +DataRaft provides a lightweight, high-level infrastructure for working with SQLite in Swift. It ensures thread-safe database access, streamlined transaction management, and a flexible migration system—without abstracting away SQL or imposing an ORM. + +Built on top of [DataLiteCore](https://github.com/angd-dev/data-lite-core) (a lightweight Swift SQLite wrapper) and [DataLiteCoder](https://github.com/angd-dev/data-lite-coder) (for type-safe encoding and decoding), DataRaft is designed for real-world applications where control, safety, and reliability are essential. + +The core philosophy behind DataRaft is to let developers retain full access to SQL while providing a simple and robust foundation for building database-powered applications. + +## Requirements + +- **Swift**: 6.0 or later +- **Platforms**: macOS 10.14+, iOS 12.0+, Linux + +## Installation + +To add DataRaft to your project, use Swift Package Manager (SPM). + +> **Important:** The API of `DataRaft` is currently unstable and may change without notice. It is **strongly recommended** to pin the dependency to a specific commit to ensure compatibility and avoid unexpected breakage when the API evolves. + +### Adding to an Xcode Project + +1. Open your project in Xcode. +2. Navigate to the `File` menu and select `Add Package Dependencies`. +3. Enter the repository URL: `https://github.com/angd-dev/data-raft.git` +4. Choose the version to install. +5. Add the library to your target module. + +### Adding to Package.swift + +If you are using Swift Package Manager with a `Package.swift` file, add the dependency like this: + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "YourProject", + dependencies: [ + .package(url: "https://github.com/angd-dev/data-raft.git", branch: "develop") + ], + targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "DataRaft", package: "data-raft") + ] + ) + ] +) +``` + +## Additional Resources + +For more information and usage examples, see the [documentation](https://docs.angd.dev/?package=data-raft&version=develop). You can also explore related projects like [DataLiteCore](https://github.com/angd-dev/data-lite-core) and [DataLiteCoder](https://github.com/angd-dev/data-lite-coder). + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift new file mode 100644 index 0000000..5c80e2d --- /dev/null +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -0,0 +1,251 @@ +import Foundation +import DataLiteCore +import DataLiteC + +/// A base class for services that operate on a database connection. +/// +/// `DatabaseService` provides a shared interface for executing operations on a `Connection`, +/// with support for transaction handling and optional request serialization. +/// +/// Subclasses can use this base to coordinate safe, synchronous access to the database +/// without duplicating concurrency or transaction logic. +/// +/// For example, you can define a custom service for managing notes: +/// +/// ```swift +/// final class NoteService: DatabaseService { +/// func insertNote(_ text: String) throws { +/// try perform { connection in +/// let stmt = try connection.prepare( +/// sql: "INSERT INTO notes (text) VALUES (?)" +/// ) +/// try stmt.bind(text, at: 0) +/// try stmt.step() +/// } +/// } +/// +/// func fetchNotes() throws -> [String] { +/// try perform { connection in +/// let stmt = try connection.prepare(sql: "SELECT text FROM notes") +/// var result: [String] = [] +/// while try stmt.step() { +/// if let text: String = stmt.columnValue(at: 0) { +/// result.append(text) +/// } +/// } +/// return result +/// } +/// } +/// } +/// +/// let connection = try Connection(location: .inMemory, options: .readwrite) +/// let service = NoteService(connection: connection) +/// +/// try service.insertNote("Hello, world!") +/// let notes = try service.fetchNotes() +/// print(notes) // ["Hello, world!"] +/// ``` +/// +/// This approach allows you to build reusable service layers on top of a safe, transactional, +/// and serialized foundation. +open class DatabaseService: DatabaseServiceProtocol { + /// A closure that provides a new database connection when invoked. + /// + /// `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. + /// + /// - Returns: A valid `Connection` instance. + /// - Throws: Any error encountered while opening or configuring the connection. + public typealias ConnectionProvider = () throws -> Connection + + // MARK: - Properties + + private let provider: ConnectionProvider + private var connection: Connection + private let queue: DispatchQueue + private let queueKey = DispatchSpecificKey() + + /// The object that 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. + /// + /// If an error occurs during key retrieval or application, the service notifies the provider + /// by calling `databaseService(_:didReceive:)`. + /// + /// 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. + 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) + } + } + } + } + + // 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 + /// 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. + /// + /// - 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. + public init( + provider: @escaping ConnectionProvider, + queue: DispatchQueue? = nil + ) rethrows { + self.provider = provider + 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) + } + } + + // 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. + /// + /// The operation is executed synchronously on the internal dispatch queue via `perform(_:)` + /// to ensure thread safety. + /// + /// - Throws: Any error thrown during connection creation or while retrieving or applying the + /// encryption key. + public func reconnect() throws { + try perform { _ in + let connection = try provider() + if let key = try keyProvider?.databaseServiceKey(self) { + try connection.apply(key) + } + 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. + /// + /// - 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. + 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 + } + } + } + + /// 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 thrown error is a `SQLiteError` with code `SQLITE_NOTADB`, the service attempts to + /// reconnect and retries the entire transaction block exactly 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. + /// + /// - Parameters: + /// - transaction: The type of transaction to begin (e.g., `deferred`, `immediate`, `exclusive`). + /// - 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. + public func perform( + in transaction: TransactionType, + closure: Perform + ) rethrows -> T { + if connection.isAutocommit { + try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + try connection.rollbackTransaction() + guard let error = error as? Connection.Error, + error.code == SQLITE_NOTADB + else { throw error } + + try reconnect() + + return try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + try connection.rollbackTransaction() + throw error + } + } + } + } + } else { + try perform(closure) + } + } +} diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift new file mode 100644 index 0000000..c3324a2 --- /dev/null +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -0,0 +1,138 @@ +import Foundation +import DataLiteCore + +/// A service responsible for managing and applying database migrations in a versioned manner. +/// +/// `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. +/// +/// 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. +/// +/// ```swift +/// let connection = try Connection(location: .inMemory, options: .readwrite) +/// let storage = UserVersionStorage() +/// let service = MigrationService(storage: storage, connection: connection) +/// +/// 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. + 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 migrations = Set>() + + /// The 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. + /// + /// - Parameters: + /// - service: The database service used to perform migrations. + /// - storage: The version storage implementation used to track the current schema version. + public init( + service: Service, + storage: Storage + ) { + self.service = service + self.storage = storage + } + + // MARK: - Migration Management + + /// Registers a new migration. + /// + /// Ensures that no other migration with the same version or script URL has been registered. + /// + /// - 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 { + guard !migrations.contains(where: { + $0.version == migration.version + || $0.scriptURL == migration.scriptURL + }) else { + throw Error.duplicateMigration(migration) + } + migrations.insert(migration) + } + + /// Executes all pending migrations in ascending version order. + /// + /// 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. + /// + /// If a migration script is empty or a migration fails, the process aborts and rolls back changes. + /// + /// - Throws: ``Error/migrationFailed(_:_:)`` if a migration script fails or if updating the version fails. + public func migrate() throws { + do { + try service.perform(in: .exclusive) { connection in + try storage.prepare(connection) + let version = try storage.getVersion(connection) + let migrations = migrations + .filter { $0.version > version } + .sorted { $0.version < $1.version } + + for migration in migrations { + let script = try migration.script + guard !script.isEmpty else { + throw Error.emptyMigrationScript(migration) + } + do { + try connection.execute(sql: script) + } catch { + throw Error.migrationFailed(migration, error) + } + } + + if let version = migrations.last?.version { + try storage.setVersion(connection, version) + } + } + } catch let error as Error { + throw error + } catch { + throw Error.migrationFailed(nil, error) + } + } +} diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift new file mode 100644 index 0000000..079d382 --- /dev/null +++ b/Sources/DataRaft/Classes/RowDatabaseService.swift @@ -0,0 +1,111 @@ +import Foundation +import DataLiteCore +import DataLiteCoder + +/// A database service that provides built-in row encoding and decoding. +/// +/// `RowDatabaseService` extends `DatabaseService` by adding support for +/// value serialization using `RowEncoder` and deserialization using `RowDecoder`. +/// +/// This enables subclasses to perform type-safe operations on models +/// encoded from or decoded into SQLite row representations. +/// +/// For example, a concrete service might define model-aware fetch or insert methods: +/// +/// ```swift +/// struct User: Codable { +/// let id: Int +/// let name: String +/// } +/// +/// final class UserService: RowDatabaseService { +/// func fetchUsers() throws -> [User] { +/// try perform(in: .deferred) { connection in +/// let stmt = try connection.prepare(sql: "SELECT * FROM users") +/// let rows = try stmt.execute() +/// return try decoder.decode([User].self, from: rows) +/// } +/// } +/// +/// func insertUser(_ user: User) throws { +/// try perform(in: .deferred) { connection in +/// let row = try encoder.encode(user) +/// let columns = row.columns.joined(separator: ", ") +/// let parameters = row.namedParameters.joined(separator: ", ") +/// let stmt = try connection.prepare( +/// sql: "INSERT INTO users (\(columns)) VALUES (\(parameters))" +/// ) +/// try stmt.execute(rows: [row]) +/// } +/// } +/// } +/// ``` +/// +/// `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 { + // MARK: - Properties + + /// The encoder used to serialize values into row representations. + public let encoder: RowEncoder + + /// The decoder used to deserialize row values into strongly typed models. + public let decoder: RowDecoder + + // MARK: - Inits + + /// Creates a new `RowDatabaseService`. + /// + /// This initializer accepts a closure that supplies the database connection. If no encoder + /// or decoder is provided, default instances are used. + /// + /// - Parameters: + /// - provider: A closure that returns a `Connection` instance. May throw an error. + /// - encoder: The encoder used to serialize models into SQLite-compatible rows. + /// Defaults to a new encoder. + /// - decoder: The decoder used to deserialize SQLite rows into typed models. + /// Defaults to a new decoder. + /// - queue: An optional dispatch queue used for serialization. If `nil`, an internal + /// serial queue with `.utility` QoS is created. + /// - Throws: Any error thrown by the connection provider. + public convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + encoder: RowEncoder = RowEncoder(), + decoder: RowDecoder = RowDecoder(), + queue: DispatchQueue? = nil + ) rethrows { + try self.init( + provider: provider, + encoder: encoder, + decoder: decoder, + queue: queue + ) + } + + /// Designated initializer for `RowDatabaseService`. + /// + /// Initializes a new instance with the specified connection provider, encoder, decoder, + /// and an optional dispatch queue for synchronization. + /// + /// - Parameters: + /// - provider: A closure that returns a `Connection` instance. May throw an error. + /// - encoder: A custom `RowEncoder` used for encoding model data. Defaults to a new encoder. + /// - decoder: A custom `RowDecoder` used for decoding database rows. Defaults to a new decoder. + /// - queue: An optional dispatch queue for serializing access to the database connection. + /// If `nil`, a default internal serial queue with `.utility` QoS is used. + /// - Throws: Any error thrown by the connection provider. + public init( + provider: @escaping ConnectionProvider, + encoder: RowEncoder = RowEncoder(), + decoder: RowDecoder = RowDecoder(), + queue: DispatchQueue? = nil + ) rethrows { + self.encoder = encoder + self.decoder = decoder + try super.init( + provider: provider, + queue: queue + ) + } +} diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift new file mode 100644 index 0000000..1da6303 --- /dev/null +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -0,0 +1,66 @@ +import Foundation +import DataLiteCore + +/// A database version storage that uses the `user_version` field. +/// +/// This class implements ``VersionStorage`` by storing version information +/// in the SQLite `PRAGMA user_version` field. It provides a lightweight, +/// type-safe way to persist versioning data in a database. +/// +/// The generic `Version` type must conform to both ``VersionRepresentable`` +/// and `RawRepresentable`, where `RawValue == UInt32`. This allows +/// converting between stored integer values and semantic version types +/// defined by the application. +public final class UserVersionStorage< + Version: VersionRepresentable & RawRepresentable +>: 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. + case invalidStoredVersion(UInt32) + } + + // MARK: - Inits + + /// Creates a new user version storage instance. + public init() {} + + // MARK: - Methods + + /// Returns the current version stored in the `user_version` field. + /// + /// This method reads the `PRAGMA user_version` value and attempts to + /// decode it into a valid `Version` value. If the stored value is not + /// recognized, it throws an error. + /// + /// - Parameter connection: The database connection. + /// - Returns: A decoded version value of type `Version`. + /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value + /// cannot be mapped to a valid `Version` instance. + public func getVersion( + _ connection: Connection + ) throws -> Version { + let raw = UInt32(bitPattern: connection.userVersion) + guard let version = Version(rawValue: raw) else { + throw Error.invalidStoredVersion(raw) + } + return version + } + + /// Stores the given version in the `user_version` field. + /// + /// This method updates the `PRAGMA user_version` field + /// with the raw `UInt32` value of the provided `Version`. + /// + /// - Parameters: + /// - connection: The database connection. + /// - version: The version to store. + public func setVersion( + _ connection: Connection, + _ version: Version + ) throws { + connection.userVersion = .init( + bitPattern: version.rawValue + ) + } +} diff --git a/Sources/DataRaft/Extensions/DispatchQueue.swift b/Sources/DataRaft/Extensions/DispatchQueue.swift new file mode 100644 index 0000000..1b80de5 --- /dev/null +++ b/Sources/DataRaft/Extensions/DispatchQueue.swift @@ -0,0 +1,19 @@ +import Foundation + +extension DispatchQueue { + convenience init( + for type: T.Type, + qos: DispatchQoS = .unspecified, + attributes: Attributes = [], + autoreleaseFrequency: AutoreleaseFrequency = .inherit, + target: DispatchQueue? = nil + ) { + self.init( + label: String(describing: type), + qos: qos, + attributes: attributes, + autoreleaseFrequency: autoreleaseFrequency, + target: target + ) + } +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift new file mode 100644 index 0000000..840d4b5 --- /dev/null +++ b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift @@ -0,0 +1,53 @@ +import Foundation +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. +/// +/// 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). +/// +/// You can also implement error handling or diagnostics via the optional +/// ``databaseService(_:didReceive:)`` method. +/// +/// - Tip: You may throw from ``databaseServiceKey(_:)`` to indicate that the key is temporarily +/// unavailable or access is denied. +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. + /// + /// - 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. + /// + /// 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. + /// + /// The default implementation is a no-op. + /// + /// - Parameters: + /// - service: The database service reporting the error. + /// - error: The error encountered during key retrieval or application. + func databaseService(_ service: DatabaseService, didReceive error: Error) +} + +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. + func databaseService(_ service: DatabaseService, didReceive error: Error) {} +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift new file mode 100644 index 0000000..ddd73a4 --- /dev/null +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -0,0 +1,55 @@ +import Foundation +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. +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. + /// + /// - 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. + 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. + 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. + /// + /// - Throws: Any error that occurs during connection creation or key application. + func reconnect() throws + + /// Executes the given closure with a live connection. + /// + /// - Parameter closure: The operation to execute. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown during execution. + 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. + /// + /// - Parameters: + /// - transaction: The transaction type to begin. + /// - closure: The operation to execute within the transaction. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown by the closure or transaction. + func perform( + in transaction: TransactionType, + closure: Perform + ) rethrows -> T +} diff --git a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift new file mode 100644 index 0000000..8422ccf --- /dev/null +++ b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift @@ -0,0 +1,17 @@ +import Foundation +import DataLiteCoder + +/// A protocol for database services that support row encoding and decoding. +/// +/// Conforming types provide `RowEncoder` and `RowDecoder` instances for serializing +/// and deserializing model types to and from SQLite row representations. +/// +/// This enables strongly typed, reusable, and safe access to database records +/// using Swift's `Codable` system. +public protocol RowDatabaseServiceProtocol: DatabaseServiceProtocol { + /// The encoder used to serialize values into database rows. + var encoder: RowEncoder { get } + + /// The decoder used to deserialize database rows into typed models. + var decoder: RowDecoder { get } +} diff --git a/Sources/DataRaft/Protocols/VersionRepresentable.swift b/Sources/DataRaft/Protocols/VersionRepresentable.swift new file mode 100644 index 0000000..8a276c8 --- /dev/null +++ b/Sources/DataRaft/Protocols/VersionRepresentable.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A constraint that defines the requirements for a type used as a database schema version. +/// +/// This type alias specifies the minimal set of capabilities a version type must have +/// to participate in schema migrations. Conforming types must be: +/// +/// - `Equatable`: to check whether two versions are equal +/// - `Comparable`: to compare versions and determine ordering +/// - `Hashable`: to use versions as dictionary keys or in sets +/// - `Sendable`: to ensure safe use in concurrent contexts +/// +/// Use this alias as a base constraint when defining custom version types +/// for use with ``VersionStorage``. +/// +/// ```swift +/// struct SemanticVersion: VersionRepresentable { +/// let major: Int +/// let minor: Int +/// let patch: Int +/// +/// static func < (lhs: Self, rhs: Self) -> Bool { +/// if lhs.major != rhs.major { +/// return lhs.major < rhs.major +/// } +/// if lhs.minor != rhs.minor { +/// return lhs.minor < rhs.minor +/// } +/// return lhs.patch < rhs.patch +/// } +/// } +/// ``` +public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable diff --git a/Sources/DataRaft/Protocols/VersionStorage.swift b/Sources/DataRaft/Protocols/VersionStorage.swift new file mode 100644 index 0000000..1bcfdd5 --- /dev/null +++ b/Sources/DataRaft/Protocols/VersionStorage.swift @@ -0,0 +1,140 @@ +import Foundation +import DataLiteCore + +/// A protocol that defines how the database version is stored and retrieved. +/// +/// This protocol decouples the concept of version representation from +/// the way the version is stored. It enables flexible implementations +/// that can store version values in different forms and places. +/// +/// The associated `Version` type determines how the version is represented +/// (e.g. as an integer, a semantic string, or a structured object), while the +/// conforming type defines how that version is persisted. +/// +/// Use this protocol to implement custom strategies for version tracking: +/// - Store an integer version in SQLite's `user_version` field. +/// - Store a string in a dedicated metadata table. +/// - Store structured data in a JSON column. +/// +/// To define your own versioning mechanism, implement `VersionStorage` +/// and choose a `Version` type that conforms to ``VersionRepresentable``. +/// +/// You can implement this protocol to define a custom way of storing the version +/// of a database schema. For example, the version could be a string stored in a metadata table. +/// +/// Below is an example of a simple implementation that stores the version string +/// in a table named `schema_version`. +/// +/// ```swift +/// final class StringVersionStorage: VersionStorage { +/// typealias Version = String +/// +/// func prepare(_ connection: Connection) throws { +/// let script: SQLScript = """ +/// CREATE TABLE IF NOT EXISTS schema_version ( +/// version TEXT NOT NULL +/// ); +/// +/// INSERT INTO schema_version (version) +/// SELECT '0.0.0' +/// WHERE NOT EXISTS (SELECT 1 FROM schema_version); +/// """ +/// try connection.execute(sql: script) +/// } +/// +/// func getVersion(_ connection: Connection) throws -> Version { +/// let query = "SELECT version FROM schema_version LIMIT 1" +/// let stmt = try connection.prepare(sql: query) +/// guard try stmt.step(), let value: Version = stmt.columnValue(at: 0) else { +/// throw DatabaseError.message("Missing version in schema_version table.") +/// } +/// return value +/// } +/// +/// func setVersion(_ connection: Connection, _ version: Version) throws { +/// let query = "UPDATE schema_version SET version = ?" +/// let stmt = try connection.prepare(sql: query) +/// try stmt.bind(version, at: 0) +/// try stmt.step() +/// } +/// } +/// ``` +/// +/// This implementation works as follows: +/// +/// - `prepare(_:)` creates the `schema_version` table if it does not exist, and ensures that it +/// contains exactly one row with an initial version value (`"0.0.0"`). +/// +/// - `getVersion(_:)` reads the current version string from the single row in the table. +/// If the row is missing, it throws an error. +/// +/// - `setVersion(_:_:)` updates the version string in that row. A `WHERE` clause is not necessary +/// because the table always contains exactly one row. +/// +/// ## Topics +/// +/// ### Associated Types +/// +/// - ``Version`` +/// +/// ### Instance Methods +/// +/// - ``prepare(_:)`` +/// - ``getVersion(_:)`` +/// - ``setVersion(_:_:)`` +public protocol VersionStorage { + /// A type representing the database schema version. + associatedtype Version: VersionRepresentable + + /// Prepares the storage mechanism for tracking the schema version. + /// + /// This method is called before any version operations. Use it to create required tables + /// or metadata structures needed for version management. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameter connection: The database connection used for schema preparation. + /// - Throws: An error if preparation fails. + func prepare(_ connection: Connection) throws + + /// Returns the current schema version stored in the database. + /// + /// This method must return a valid version previously stored by the migration system. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameter connection: The database connection used to fetch the version. + /// - Returns: The version currently stored in the database. + /// - Throws: An error if reading fails or the version is missing. + func getVersion(_ connection: Connection) throws -> Version + + /// Stores the given version as the current schema version. + /// + /// This method is called at the end of the migration process to persist + /// the final schema version after all migration steps have completed successfully. + /// + /// - Important: This method is executed within an active migration transaction. + /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, + /// the entire migration process will be aborted and rolled back. + /// + /// - Parameters: + /// - connection: The database connection used to write the version. + /// - version: The version to store. + /// - Throws: An error if writing fails. + func setVersion(_ connection: Connection, _ version: Version) throws +} + +public extension VersionStorage { + /// A default implementation that performs no preparation. + /// + /// Override this method if your storage implementation requires any setup, + /// such as creating a version table or inserting an initial value. + /// + /// If you override this method and it throws an error, the migration process + /// will be aborted and rolled back. + func prepare(_ connection: Connection) throws {} +} diff --git a/Sources/DataRaft/Structures/BitPackVersion.swift b/Sources/DataRaft/Structures/BitPackVersion.swift new file mode 100644 index 0000000..f74add6 --- /dev/null +++ b/Sources/DataRaft/Structures/BitPackVersion.swift @@ -0,0 +1,158 @@ +import Foundation + +/// A semantic version packed into a 32-bit unsigned integer. +/// +/// This type stores a `major.minor.patch` version using bit fields inside a single `UInt32`: +/// +/// - 12 bits for `major` in the 0...4095 range +/// - 12 bits for `minor`in the 0...4095 range +/// - 8 bits for `patch` in the 0...255 range +/// +/// ## Topics +/// +/// ### Errors +/// +/// - ``Error`` +/// - ``ParseError`` +/// +/// ### Creating a Version +/// +/// - ``init(rawValue:)`` +/// - ``init(major:minor:patch:)`` +/// - ``init(version:)`` +/// - ``init(stringLiteral:)`` +/// +/// ### Instance Properties +/// +/// - ``rawValue`` +/// - ``major`` +/// - ``minor`` +/// - ``patch`` +/// - ``description`` +public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStringConvertible { + /// An error related to invalid version components. + public enum Error: Swift.Error { + /// An error for a major component that exceeds the allowed range. + case majorOverflow(UInt32) + + /// An error for a minor component that exceeds the allowed range. + case minorOverflow(UInt32) + + /// An error for a patch component that exceeds the allowed range. + case patchOverflow(UInt32) + + /// A message describing the reason for the error. + public var localizedDescription: String { + switch self { + case .majorOverflow(let value): + "Major version overflow: \(value). Allowed range: 0...4095." + case .minorOverflow(let value): + "Minor version overflow: \(value). Allowed range: 0...4095." + case .patchOverflow(let value): + "Patch version overflow: \(value). Allowed range: 0...255." + } + } + } + + // MARK: - Properties + + /// The packed 32-bit value that encodes the version. + public let rawValue: UInt32 + + /// The major component of the version. + public var major: UInt32 { (rawValue >> 20) & 0xFFF } + + /// The minor component of the version. + public var minor: UInt32 { (rawValue >> 8) & 0xFFF } + + /// The patch component of the version. + public var patch: UInt32 { rawValue & 0xFF } + + /// A string representation in the form `"major.minor.patch"`. + public var description: String { + "\(major).\(minor).\(patch)" + } + + // MARK: - Inits + + /// Creates a version from a packed 32-bit unsigned integer. + /// + /// - Parameter rawValue: A bit-packed version value. + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// Creates a version from individual components. + /// + /// - Parameters: + /// - major: The major component in the 0...4095 range. + /// - minor: The minor component in the 0...4095 range. + /// - patch: The patch component in the 0...255 range. Defaults to `0`. + /// + /// - Throws: ``Error/majorOverflow(_:)`` if `major` is out of range. + /// - Throws: ``Error/minorOverflow(_:)`` if `minor` is out of range. + /// - Throws: ``Error/patchOverflow(_:)`` if `patch` is out of range. + public init(major: UInt32, minor: UInt32, patch: UInt32 = 0) throws { + guard major < (1 << 12) else { throw Error.majorOverflow(major) } + guard minor < (1 << 12) else { throw Error.minorOverflow(minor) } + guard patch < (1 << 8) else { throw Error.patchOverflow(patch) } + self.init(rawValue: (major << 20) | (minor << 8) | patch) + } + + // MARK: - Comparable + + /// Compares two versions by their packed 32-bit values. + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +// MARK: - ExpressibleByStringLiteral + +@available(iOS 16.0, *) +@available(macOS 13.0, *) +extension BitPackVersion: ExpressibleByStringLiteral { + /// An error related to parsing a version string. + public enum ParseError: Swift.Error { + /// A string that doesn't match the expected version format. + case invalidFormat(String) + + /// A message describing the format issue. + public var localizedDescription: String { + switch self { + case .invalidFormat(let str): + "Invalid version format: \(str). Expected something like '1.2' or '1.2.3'." + } + } + } + + /// Creates a version by parsing a string like `"1.2"` or `"1.2.3"`. + /// + /// - Parameter version: A version string in the form `x.y` or `x.y.z`. + /// + /// - Throws: ``ParseError/invalidFormat(_:)`` if the string format is invalid. + /// - Throws: `Error` if any component is out of range. + public init(version: String) throws { + let regex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/ + guard version.wholeMatch(of: regex) != nil else { + throw ParseError.invalidFormat(version) + } + + let parts = version.split(separator: ".") + .compactMap { UInt32($0) } + + try self.init( + major: parts[0], + minor: parts[1], + patch: parts.count == 3 ? parts[2] : 0 + ) + } + + /// Creates a version from a string literal like `"1.2"` or `"1.2.3"`. + /// + /// - Warning: Crashes if the string format is invalid. + /// Use ``init(version:)`` for safe parsing. + public init(stringLiteral value: String) { + try! self.init(version: value) + } +} diff --git a/Sources/DataRaft/Structures/Migration.swift b/Sources/DataRaft/Structures/Migration.swift new file mode 100644 index 0000000..12d40da --- /dev/null +++ b/Sources/DataRaft/Structures/Migration.swift @@ -0,0 +1,75 @@ +import Foundation +import DataLiteCore + +/// Represents a database migration step associated with a specific version. +/// +/// Each `Migration` contains a reference to a migration script file (usually a `.sql` file) and the +/// version to which this script corresponds. The script is expected to be bundled with the application. +/// +/// You can initialize a migration directly with a URL to the script, or load it from a resource +/// embedded in a bundle. +public struct Migration: Hashable, Sendable { + // MARK: - Properties + + /// The version associated with this migration step. + public let version: Version + + /// The URL pointing to the migration script (e.g., an SQL file). + public let scriptURL: URL + + /// The SQL script associated with this migration. + /// + /// This computed property reads the contents of the file at `scriptURL` and returns it as a + /// `SQLScript` instance. Use this to access and execute the migration's SQL commands. + /// + /// - Throws: An error if the script file cannot be read or is invalid. + public var script: SQLScript { + get throws { + try SQLScript(contentsOf: scriptURL) + } + } + + // MARK: - Inits + + /// Creates a migration with a specified version and script URL. + /// + /// - Parameters: + /// - version: The version this migration corresponds to. + /// - scriptURL: The file URL to the migration script. + public init(version: Version, scriptURL: URL) { + self.version = version + self.scriptURL = scriptURL + } + + /// Creates a migration by locating a script resource in the specified bundle. + /// + /// This initializer attempts to locate a script file in the provided bundle using the specified + /// resource `name` and optional `extension`. The `name` parameter may include or omit the file extension. + /// + /// - If `name` includes an extension (e.g., `"001_init.sql"`), pass `extension` as `nil` or an empty string. + /// - If `name` omits the extension (e.g., `"001_init"`), specify the extension separately + /// (e.g., `"sql"`), or leave it `nil` if the file has no extension. + /// + /// - Important: Passing a name that already includes the extension while also specifying a non-`nil` + /// `extension` may result in failure to locate the file. + /// + /// - Parameters: + /// - version: The version this migration corresponds to. + /// - name: The resource name of the script file. May include or omit the file extension. + /// - extension: The file extension, if separated from the name. Defaults to `nil`. + /// - bundle: The bundle in which to search for the resource. Defaults to `.main`. + /// + /// - Returns: A `Migration` if the resource file is found; otherwise, `nil`. + public init?( + version: Version, + byResource name: String, + extension: String? = nil, + in bundle: Bundle = .main + ) { + guard let url = bundle.url( + forResource: name, + withExtension: `extension` + ) else { return nil } + self.init(version: version, scriptURL: url) + } +} diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift new file mode 100644 index 0000000..ce04436 --- /dev/null +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +import DataLiteC +import DataLiteCore +import DataRaft + +class DatabaseServiceTests: DatabaseServiceKeyProvider { + private let keyOne = Connection.Key.rawKey(Data([ + 0xe8, 0xd7, 0x92, 0xa2, 0xa1, 0x35, 0x56, 0xc0, + 0xfd, 0xbb, 0x2f, 0x91, 0xe8, 0x0b, 0x4b, 0x2a, + 0xa2, 0xd7, 0x78, 0xe9, 0xe5, 0x87, 0x05, 0xb4, + 0xe2, 0x1a, 0x42, 0x74, 0xee, 0xbc, 0x4c, 0x06 + ])) + + private let keyTwo = Connection.Key.rawKey(Data([ + 0x9f, 0x45, 0x23, 0xbf, 0xfe, 0x11, 0x3e, 0x79, + 0x42, 0x21, 0x48, 0x7c, 0xb6, 0xb1, 0xd5, 0x09, + 0x34, 0x5f, 0xcb, 0x53, 0xa3, 0xdd, 0x8e, 0x41, + 0x95, 0x27, 0xbb, 0x4e, 0x6e, 0xd8, 0xa7, 0x05 + ])) + + private let fileURL: URL + private let service: DatabaseService + + private lazy var currentKey = keyOne + + init() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("sqlite") + + let service = try DatabaseService(provider: { + try Connection( + path: fileURL.path, + options: [.create, .readwrite] + ) + }) + + self.fileURL = fileURL + self.service = service + self.service.keyProvider = self + + try self.service.perform { connection in + try connection.execute(sql: """ + CREATE TABLE IF NOT EXISTS Item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + """) + } + } + + deinit { + try? FileManager.default.removeItem(at: fileURL) + } + + func databaseServiceKey(_ service: DatabaseService) throws -> Connection.Key { + currentKey + } +} + +extension DatabaseServiceTests { + @Test func testSuccessPerformTransaction() throws { + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testNestedPerformTransaction() throws { + try service.perform(in: .deferred) { _ in + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testRollbackPerformTransaction() throws { + struct DummyError: Error, Equatable {} + #expect(throws: DummyError(), performing: { + try self.service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + throw DummyError() + } + }) + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 0) + } + } + + @Test func testSuccessReconnectPerformTransaction() throws { + let connection = try Connection( + path: fileURL.path, + options: [.readwrite] + ) + try connection.apply(currentKey) + try connection.rekey(keyTwo) + currentKey = keyTwo + + try service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(stmt.columnValue(at: 0) == 1) + } + } + + @Test func testFailReconnectPerformTransaction() throws { + let connection = try Connection( + path: fileURL.path, + options: [.readwrite] + ) + try connection.apply(currentKey) + try connection.rekey(keyTwo) + let error = Connection.Error( + code: SQLITE_NOTADB, + message: "file is not a database" + ) + #expect(throws: error, performing: { + try self.service.perform(in: .deferred) { connection in + #expect(connection.isAutocommit == false) + let stmt = try connection.prepare( + sql: "INSERT INTO Item (name) VALUES (?)", + options: [] + ) + try stmt.bind("Book", at: 1) + try stmt.step() + } + }) + currentKey = keyTwo + try service.reconnect() + try service.perform { connection in + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 0) + } + } +} diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift new file mode 100644 index 0000000..9f145e6 --- /dev/null +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -0,0 +1,101 @@ +import Testing +import DataLiteCore +@testable import DataRaft + +@Suite struct MigrationServiceTests { + private typealias MigrationService = DataRaft.MigrationService + + private var connection: Connection! + private var migrationService: MigrationService! + + init() throws { + let connection = try Connection(location: .inMemory, options: .readwrite) + self.connection = connection + self.migrationService = .init(service: .init(connection: connection), storage: .init()) + } + + @Test func addMigration() 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_2", extension: "sql", in: .module)! + + #expect(try migrationService.add(migration1) == ()) + #expect(try migrationService.add(migration2) == ()) + + do { + try migrationService.add(migration3) + Issue.record("Expected duplicateMigration error for version \(migration3.version)") + } catch MigrationService.Error.duplicateMigration(let migration) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func migrate() 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() + + #expect(connection.userVersion == 2) + } + + @Test func migrateWithError() 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)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try migrationService.add(migration3) + + do { + try migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration3.version)") + } catch MigrationService.Error.migrationFailed(let migration, _) { + #expect(migration == migration3) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(connection.userVersion == 0) + } + + @Test func migrateWithEmptyMigration() 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)! + + try migrationService.add(migration1) + try migrationService.add(migration2) + try migrationService.add(migration4) + + do { + try migrationService.migrate() + Issue.record("Expected migrationFailed error for version \(migration4.version)") + } catch MigrationService.Error.emptyMigrationScript(let migration) { + #expect(migration == migration4) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(connection.userVersion == 0) + } +} + +private extension MigrationServiceTests { + struct VersionStorage: DataRaft.VersionStorage { + typealias Version = Int32 + + func getVersion(_ connection: Connection) throws -> Version { + connection.userVersion + } + + func setVersion(_ connection: Connection, _ version: Version) throws { + connection.userVersion = version + } + } +} diff --git a/Tests/DataRaftTests/Classes/UserVersionStorage.swift b/Tests/DataRaftTests/Classes/UserVersionStorage.swift new file mode 100644 index 0000000..f595dd2 --- /dev/null +++ b/Tests/DataRaftTests/Classes/UserVersionStorage.swift @@ -0,0 +1,64 @@ +import Testing +import DataLiteCore +@testable import DataRaft + +@Suite struct UserVersionStorageTests { + private var connection: Connection! + + init() throws { + connection = try .init(location: .inMemory, options: .readwrite) + } + + @Test func getVersion() throws { + connection.userVersion = 123 + let storage = UserVersionStorage() + let version = try storage.getVersion(connection) + #expect(version == Version(rawValue: 123)) + } + + @Test func getVersionWithError() { + connection.userVersion = 123 + let storage = UserVersionStorage() + do { + _ = try storage.getVersion(connection) + Issue.record("Expected failure for invalid stored version") + } catch UserVersionStorage.Error.invalidStoredVersion(let version) { + #expect(version == UInt32(bitPattern: connection.userVersion)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func setVersion() throws { + let storage = UserVersionStorage() + let version = Version(rawValue: 456) + try storage.setVersion(connection, version) + #expect(connection.userVersion == 456) + } +} + +private extension UserVersionStorageTests { + struct Version: RawRepresentable, VersionRepresentable, Equatable { + let rawValue: UInt32 + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + struct NilVersion: RawRepresentable, VersionRepresentable { + let rawValue: UInt32 + + init?(rawValue: UInt32) { + return nil + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } +} diff --git a/Tests/DataRaftTests/Resources/migration_1.sql b/Tests/DataRaftTests/Resources/migration_1.sql new file mode 100644 index 0000000..8053cb0 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_1.sql @@ -0,0 +1,12 @@ +-- Create table User +CREATE TABLE User ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL +); + +-- Insert values into User +INSERT INTO User (id, name, email) +VALUES + (1, 'john_doe', 'john@example.com'), -- Inserting John Doe + (2, 'jane_doe', 'jane@example.com'); -- Inserting Jane Doe diff --git a/Tests/DataRaftTests/Resources/migration_2.sql b/Tests/DataRaftTests/Resources/migration_2.sql new file mode 100644 index 0000000..acab84b --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_2.sql @@ -0,0 +1,11 @@ +-- Create table Device +CREATE TABLE Device ( + id INTEGER PRIMARY KEY, + model TEXT NOT NULL +); + +-- Insert values into Device +INSERT INTO Device (id, model) +VALUES + (1, 'iPhone 14'), -- Inserting iPhone 14 + (2, 'iPhone 15'); -- Inserting iPhone 15 diff --git a/Tests/DataRaftTests/Resources/migration_3.sql b/Tests/DataRaftTests/Resources/migration_3.sql new file mode 100644 index 0000000..48d1604 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_3.sql @@ -0,0 +1,2 @@ +-- Wrong sql statement +WRONG SQL STATEMENT; diff --git a/Tests/DataRaftTests/Resources/migration_4.sql b/Tests/DataRaftTests/Resources/migration_4.sql new file mode 100644 index 0000000..af447e5 --- /dev/null +++ b/Tests/DataRaftTests/Resources/migration_4.sql @@ -0,0 +1 @@ +-- Empty Script diff --git a/Tests/DataRaftTests/Structures/BitPackVersionTests.swift b/Tests/DataRaftTests/Structures/BitPackVersionTests.swift new file mode 100644 index 0000000..6f75237 --- /dev/null +++ b/Tests/DataRaftTests/Structures/BitPackVersionTests.swift @@ -0,0 +1,182 @@ +import Testing +import DataRaft + +@Suite struct BitPackVersionTests { + @Test(arguments: [ + (0, 0, 0, 0, "0.0.0"), + (0, 0, 1, 1, "0.0.1"), + (0, 1, 0, 256, "0.1.0"), + (1, 0, 0, 1048576, "1.0.0"), + (15, 15, 15, 15732495, "15.15.15"), + (123, 456, 78, 129091662, "123.456.78"), + (255, 255, 255, 267452415, "255.255.255"), + (4095, 4095, 255, 4294967295, "4095.4095.255") + ]) + func versionComponents( + _ major: UInt32, + _ minor: UInt32, + _ patch: UInt32, + _ rawValue: UInt32, + _ description: String + ) throws { + let version = try BitPackVersion( + major: major, + minor: minor, + patch: patch + ) + + #expect(version.major == major) + #expect(version.minor == minor) + #expect(version.patch == patch) + #expect(version.rawValue == rawValue) + #expect(version.description == description) + } + + @Test func majorOverflow() { + do { + _ = try BitPackVersion(major: 4096, minor: 0) + Issue.record("Expected BitPackVersion.Error.majorOverflow, but succeeded") + } catch BitPackVersion.Error.majorOverflow(let value) { + let error = BitPackVersion.Error.majorOverflow(value) + let description = "Major version overflow: \(value). Allowed range: 0...4095." + #expect(value == 4096) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func minorOverflow() { + do { + _ = try BitPackVersion(major: 0, minor: 4096) + Issue.record("Expected BitPackVersion.Error.minorOverflow, but succeeded") + } catch BitPackVersion.Error.minorOverflow(let value) { + let error = BitPackVersion.Error.minorOverflow(value) + let description = "Minor version overflow: \(value). Allowed range: 0...4095." + #expect(value == 4096) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func patchOverflow() { + do { + _ = try BitPackVersion(major: 0, minor: 0, patch: 256) + Issue.record("Expected BitPackVersion.Error.patchOverflow, but succeeded") + } catch BitPackVersion.Error.patchOverflow(let value) { + let error = BitPackVersion.Error.patchOverflow(value) + let description = "Patch version overflow: \(value). Allowed range: 0...255." + #expect(value == 256) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test(arguments: try [ + ( + BitPackVersion(major: 0, minor: 0, patch: 0), + BitPackVersion(major: 0, minor: 0, patch: 1) + ), + ( + BitPackVersion(major: 0, minor: 1, patch: 0), + BitPackVersion(major: 1, minor: 0, patch: 0) + ), + ( + BitPackVersion(major: 1, minor: 1, patch: 1), + BitPackVersion(major: 1, minor: 1, patch: 2) + ), + ( + BitPackVersion(major: 5, minor: 0, patch: 255), + BitPackVersion(major: 5, minor: 1, patch: 0) + ), + ( + BitPackVersion(major: 10, minor: 100, patch: 100), + BitPackVersion(major: 11, minor: 0, patch: 0) + ), + ( + BitPackVersion(major: 4094, minor: 4095, patch: 255), + BitPackVersion(major: 4095, minor: 0, patch: 0) + ) + ]) + func compare( + _ versionOne: BitPackVersion, + _ versionTwo: BitPackVersion + ) throws { + #expect(versionOne < versionTwo) + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test(arguments: [ + ("0.0.0", 0, 0, 0, 0, "0.0.0"), + ("0.0.1", 0, 0, 1, 1, "0.0.1"), + ("0.1.0", 0, 1, 0, 256, "0.1.0"), + ("1.0.0", 1, 0, 0, 1048576, "1.0.0"), + ("1.2.3", 1, 2, 3, 1049091, "1.2.3"), + ("123.456.78", 123, 456, 78, 129091662, "123.456.78"), + ("4095.4095.255", 4095, 4095, 255, 4294967295, "4095.4095.255"), + ("10.20", 10, 20, 0, 10490880, "10.20.0"), + ("42.0.13", 42, 0, 13, 44040205, "42.0.13") + ]) + func fromString( + _ string: String, + _ major: UInt32, + _ minor: UInt32, + _ patch: UInt32, + _ rawValue: UInt32, + _ description: String + ) throws { + let version = try BitPackVersion(version: string) + + #expect(version.major == major) + #expect(version.minor == minor) + #expect(version.patch == patch) + #expect(version.rawValue == rawValue) + #expect(version.description == description) + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test(arguments: [ + "", + "1", + "1.", + ".1", + "1.2.3.4", + "1.2.", + "1..2", + "a.b.c", + "1.2.c", + "01.2.3", + "1.02.3", + "1.2.03", + " 1.2.3", + "1.2.3 ", + " 1.2 ", + "1,2,3", + ]) + func fromInvalidStrings(_ input: String) { + do { + _ = try BitPackVersion(version: input) + Issue.record("Expected failure for: \(input)") + } catch BitPackVersion.ParseError.invalidFormat(let str) { + let error = BitPackVersion.ParseError.invalidFormat(str) + let description = "Invalid version format: \(str). Expected something like '1.2' or '1.2.3'." + #expect(str == input) + #expect(error.localizedDescription == description) + } catch { + Issue.record("Unexpected error for: \(input) — \(error)") + } + } + + @available(iOS 16.0, *) + @available(macOS 13.0, *) + @Test func stringLiteralInit() { + let version: BitPackVersion = "1.2.3" + #expect(version.major == 1) + #expect(version.minor == 2) + #expect(version.patch == 3) + } +} diff --git a/Tests/DataRaftTests/Structures/MigrationTests.swift b/Tests/DataRaftTests/Structures/MigrationTests.swift new file mode 100644 index 0000000..17d113b --- /dev/null +++ b/Tests/DataRaftTests/Structures/MigrationTests.swift @@ -0,0 +1,69 @@ +import Testing +import Foundation + +@testable import DataRaft + +@Suite struct MigrationTests { + @Test func initWithURL() { + let version = DummyVersion(rawValue: 1) + let url = URL(fileURLWithPath: "/tmp/migration.sql") + let migration = Migration(version: version, scriptURL: url) + + #expect(migration.version == version) + #expect(migration.scriptURL == url) + } + + @Test func initFromBundle_success() throws { + let bundle = Bundle.module // или другой, если тестовая ресурсная цель другая + let version = DummyVersion(rawValue: 2) + + let migration = Migration( + version: version, + byResource: "migration_1", + extension: "sql", + in: bundle + ) + + #expect(migration != nil) + #expect(migration?.version == version) + #expect(migration?.scriptURL.lastPathComponent == "migration_1.sql") + } + + @Test func initFromBundle_failure() { + let version = DummyVersion(rawValue: 3) + + let migration = Migration( + version: version, + byResource: "NonexistentFile", + extension: "sql", + in: .main + ) + + #expect(migration == nil) + } + + @Test func hashableEquatable() { + let version = DummyVersion(rawValue: 5) + let url = URL(fileURLWithPath: "/tmp/migration.sql") + + let migration1 = Migration(version: version, scriptURL: url) + let migration2 = Migration(version: version, scriptURL: url) + + #expect(migration1 == migration2) + #expect(migration1.hashValue == migration2.hashValue) + } +} + +private extension MigrationTests { + struct DummyVersion: VersionRepresentable { + let rawValue: UInt32 + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } +} From 860f73b731d28cab569931e6097127772aef6c58 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Thu, 7 Aug 2025 23:50:00 +0300 Subject: [PATCH 2/7] 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)") From 0118981e0a70d77b7cd5354bec0d6dff3147ffa6 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Tue, 19 Aug 2025 16:24:51 +0300 Subject: [PATCH 3/7] Update key management for database service --- .../DataRaft/Classes/DatabaseService.swift | 233 +++++++++--------- .../DataRaft/Classes/MigrationService.swift | 10 + .../DataRaft/Classes/RowDatabaseService.swift | 4 +- .../DatabaseServiceKeyProvider.swift | 135 +++------- .../Protocols/DatabaseServiceProtocol.swift | 92 +++---- .../Protocols/MigrationServiceProtocol.swift | 36 ++- .../Classes/DatabaseServiceTests.swift | 7 +- .../Classes/MigrationServiceTests.swift | 2 +- 8 files changed, 235 insertions(+), 284 deletions(-) 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 { From d26587cfc31301d91008d61158f7aaa9e38eb988 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Thu, 21 Aug 2025 15:00:10 +0300 Subject: [PATCH 4/7] Make database connection lazy and adjust config execution order --- .../DataRaft/Classes/DatabaseService.swift | 214 +++++++++--------- .../DataRaft/Classes/MigrationService.swift | 10 - .../DataRaft/Classes/RowDatabaseService.swift | 8 +- .../DatabaseServiceKeyProvider.swift | 8 +- .../Protocols/DatabaseServiceProtocol.swift | 27 +-- .../Protocols/MigrationServiceProtocol.swift | 10 - .../Classes/DatabaseServiceTests.swift | 23 +- .../Classes/MigrationServiceTests.swift | 2 +- 8 files changed, 129 insertions(+), 173 deletions(-) diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 33eb9b0..82b4462 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -1,16 +1,20 @@ import Foundation -import DataLiteCore import DataLiteC +import DataLiteCore /// Base service for working with a database. /// -/// `DatabaseService` provides a unified interface for performing operations -/// using a database connection, with built-in support for transactions, -/// reconnection, and optional encryption key management. +/// `DatabaseService` provides a unified interface for performing operations using a database +/// connection, with built-in support for transactions, reconnection, and optional encryption +/// key management. /// -/// 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. +/// 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. +/// +/// The connection is established lazily on first use (e.g., within `perform`), not during +/// initialization. If a key provider is set, the key is applied as part of establishing or +/// restoring the connection. /// /// Below is an example of creating a service for managing notes: /// @@ -18,9 +22,7 @@ import DataLiteC /// final class NoteService: DatabaseService { /// func insertNote(_ text: String) throws { /// try perform { connection in -/// let stmt = try connection.prepare( -/// sql: "INSERT INTO notes (text) VALUES (?)" -/// ) +/// let stmt = try connection.prepare(sql: "INSERT INTO notes (text) VALUES (?)") /// try stmt.bind(text, at: 0) /// try stmt.step() /// } @@ -50,43 +52,39 @@ import DataLiteC /// /// ## Error Handling /// -/// 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. +/// All operations are executed on an internal serial queue, ensuring thread safety. If a +/// decryption 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 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 +/// If a ``keyProvider`` is set, the service uses it to obtain and apply an encryption key when +/// establishing or restoring the connection. If an error occurs while obtaining or applying the +/// key, the provider is notified through /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. /// /// ## Reconnection /// /// 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. +/// ``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:keyProvider:queue:)`` -/// - ``init(connection:keyProvider:queue:)`` +/// - ``ConnectionProvider`` +/// - ``ConnectionConfig`` +/// - ``init(provider:config:keyProvider:queue:)`` +/// - ``init(connection:config:keyProvider:queue:)`` /// /// ### Key Management /// /// - ``DatabaseServiceKeyProvider`` /// - ``keyProvider`` -/// - ``applyKeyProvider()`` -/// -/// ### Connection Management -/// -/// - ``ConnectionProvider`` -/// - ``reconnect()`` /// /// ### Database Operations /// @@ -94,6 +92,8 @@ import DataLiteC /// - ``perform(_:)`` /// - ``perform(in:closure:)`` open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { + // MARK: - Types + /// A closure that creates a new database connection. /// /// `ConnectionProvider` is used for deferred connection creation. @@ -104,146 +104,140 @@ open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { /// - Throws: An error if the connection cannot be created or configured. public typealias ConnectionProvider = () throws -> Connection + /// A closure used to configure a newly created connection. + /// + /// Called after the connection is established (and after key application if present). + /// Can be used to set PRAGMA options or perform other initialization logic. + /// + /// - Parameter connection: The newly created connection. + /// - Throws: Any error if configuration fails. + public typealias ConnectionConfig = (Connection) throws -> Void + // MARK: - Properties private let provider: ConnectionProvider + private let config: ConnectionConfig? private let queue: DispatchQueue private let queueKey = DispatchSpecificKey() - private var connection: Connection + + private var cachedConnection: Connection? + private var connection: Connection { + get throws { + guard let cachedConnection else { + let connection = try connect() + cachedConnection = connection + return connection + } + return cachedConnection + } + } /// Encryption key provider. /// - /// Used to obtain and apply a key when creating or restoring a connection. + /// Used to obtain and apply a key when establishing or restoring a connection. The key is + /// requested on first access to the connection and on reconnection if needed. public weak var keyProvider: DatabaseServiceKeyProvider? // MARK: - Inits /// Creates a new database service. /// - /// Calls `provider` to create the initial connection and configures - /// the internal serial queue for thread-safe access to the database. + /// Configures the internal serial queue for thread-safe access to the database. + /// The connection is **not** created during initialization. It is established + /// lazily on first use (for example, inside `perform`). /// /// 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. + /// If a `keyProvider` is set, the encryption key will be applied when the + /// connection is established or restored. /// /// - Parameters: /// - provider: A closure that returns a new connection. + /// - config: An optional configuration closure called after the connection + /// is created (and after key application if present). /// - 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, + config: ConnectionConfig? = nil, keyProvider: DatabaseServiceKeyProvider? = nil, queue: DispatchQueue? = nil - ) throws { + ) { self.provider = provider + self.config = config 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 database service. /// + /// The connection is created lazily on first use. If a `keyProvider` is set, + /// the key will be applied when the connection is established. + /// /// - Parameters: /// - provider: An expression that creates a new connection. + /// - config: An optional configuration closure called after the connection + /// is created (and after key application if present). /// - 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, + config: ConnectionConfig? = nil, keyProvider: DatabaseServiceKeyProvider? = nil, queue: DispatchQueue? = nil - ) throws { - try self.init(provider: provider, keyProvider: keyProvider, queue: queue) + ) { + self.init( + provider: provider, + config: config, + keyProvider: keyProvider, + queue: queue + ) } // MARK: - Methods - /// Applies the encryption key from `keyProvider` to the current connection. + /// Executes a closure with the current connection. /// - /// 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()``. - /// - /// If an error occurs while obtaining or applying the key, it is thrown further - /// and also reported to the provider via - /// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. - /// - /// - 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. - /// - /// 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) - self.connection = connection - } - } - - /// Executes a closure with the active connection. - /// - /// Runs the `closure` on the internal serial queue, ensuring - /// thread-safe access to the `Connection`. + /// Ensures thread-safe access by running the closure on the internal serial queue. + /// The connection is created lazily if needed. /// /// - Parameter closure: A closure that takes the active connection. /// - Returns: The value returned by the closure. - /// - Throws: Any error thrown by the closure. - final public func perform(_ closure: Perform) rethrows -> T { + /// - Throws: An error if the connection cannot be created or if the closure throws. + final public func perform(_ closure: Perform) throws -> 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, and commits changes on success. - /// If the closure throws an error, 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 `Connection.Error` with code `SQLITE_NOTADB` - /// and reconnection is allowed, the service attempts to reconnect and retries - /// the transaction block once. + /// If the closure throws `Connection.Error` with code `SQLITE_NOTADB` and reconnection + /// is allowed, the service attempts to create a new connection, reapply the key, and + /// retries the transaction block once. If the second attempt fails or reconnection + /// is disallowed, the error is propagated without further retries. /// - /// If a transaction is already active (connection not in autocommit mode), - /// the closure is executed 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 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 management, or - /// reconnection logic. + /// - Throws: Errors from connection creation, key application, configuration, + /// transaction management, or from the closure itself. /// - Important: The closure may be executed more than once. Ensure it is idempotent. final public func perform( in transaction: TransactionType, closure: Perform - ) rethrows -> T { + ) throws -> T { try withConnection { connection in if connection.isAutocommit { do { @@ -286,21 +280,31 @@ private extension DatabaseService { keyProvider?.databaseService(shouldReconnect: self) ?? false } - func withConnection(_ closure: Perform) rethrows -> T { + func withConnection(_ closure: Perform) throws -> T { switch DispatchQueue.getSpecific(key: queueKey) { case .none: try queue.asyncAndWait { try closure(connection) } case .some: try closure(connection) } } + func reconnect() throws { + cachedConnection = try connect() + } + + func connect() throws -> Connection { + let connection = try provider() + try applyKey(to: connection) + try config?(connection) + return connection + } + func applyKey(to connection: Connection) throws { guard let keyProvider = keyProvider else { return } do { - if let key = try keyProvider.databaseService(keyFor: self) { - let sql = "SELECT count(*) FROM sqlite_master" - try connection.apply(key) - try connection.execute(raw: sql) - } + 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) throw error diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift index 3b5190c..5c564da 100644 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -74,16 +74,6 @@ 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 df7b7e5..ed4a694 100644 --- a/Sources/DataRaft/Classes/RowDatabaseService.swift +++ b/Sources/DataRaft/Classes/RowDatabaseService.swift @@ -78,8 +78,8 @@ open class RowDatabaseService: encoder: RowEncoder = RowEncoder(), decoder: RowDecoder = RowDecoder(), queue: DispatchQueue? = nil - ) throws { - try self.init( + ) { + self.init( provider: provider, encoder: encoder, decoder: decoder, @@ -104,10 +104,10 @@ open class RowDatabaseService: encoder: RowEncoder = RowEncoder(), decoder: RowDecoder = RowDecoder(), queue: DispatchQueue? = nil - ) throws { + ) { self.encoder = encoder self.decoder = decoder - try super.init( + super.init( provider: provider, queue: queue ) diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift index d83c671..e59fc0c 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift @@ -25,13 +25,13 @@ import DataLiteCore public protocol DatabaseServiceKeyProvider: AnyObject, Sendable { /// Returns the encryption key for the specified database service. /// - /// May return `nil` if the encryption key is currently unavailable or if the database - /// does not require encryption. + /// This method must either return a valid encryption key or throw an error if + /// the key cannot be retrieved. /// /// - Parameter service: The service requesting the key. - /// - Returns: The encryption key or `nil`. + /// - Returns: The encryption key. /// - Throws: An error if the key cannot be retrieved. - func databaseService(keyFor service: DatabaseServiceProtocol) throws -> Connection.Key? + func databaseService(keyFor service: DatabaseServiceProtocol) throws -> Connection.Key /// Indicates whether the service should attempt to reconnect if applying the key fails. /// diff --git a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift index efe633b..6066927 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -17,11 +17,6 @@ import DataLiteCore /// /// - ``DatabaseServiceKeyProvider`` /// - ``keyProvider`` -/// - ``applyKeyProvider()`` -/// -/// ### Connection Management -/// -/// - ``reconnect()`` /// /// ### Database Operations /// @@ -47,24 +42,6 @@ public protocol DatabaseServiceProtocol: AnyObject, Sendable { /// encountered while applying a key. var keyProvider: DatabaseServiceKeyProvider? { get set } - /// Applies the encryption key from the current provider. - /// - /// 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: 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 an active connection. /// /// The closure receives the connection and may perform any @@ -73,7 +50,7 @@ public protocol DatabaseServiceProtocol: AnyObject, Sendable { /// - 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 + func perform(_ closure: Perform) throws -> T /// Executes the given closure within a transaction. /// @@ -86,5 +63,5 @@ public protocol DatabaseServiceProtocol: AnyObject, Sendable { /// - 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 + func perform(in transaction: TransactionType, closure: Perform) throws -> T } diff --git a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift index 88973d9..f1cde72 100644 --- a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -15,16 +15,6 @@ public protocol MigrationServiceProtocol: AnyObject, Sendable { /// Encryption key provider for the database service. var keyProvider: DatabaseServiceKeyProvider? { get set } - /// 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. diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift index 9f05454..8af5157 100644 --- a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift +++ b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift @@ -29,7 +29,7 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { .appendingPathComponent(UUID().uuidString) .appendingPathExtension("sqlite") - let service = try DatabaseService(provider: { + let service = DatabaseService(provider: { try Connection( path: fileURL.path, options: [.create, .readwrite] @@ -40,7 +40,6 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { 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 ( @@ -55,7 +54,7 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { try? FileManager.default.removeItem(at: fileURL) } - func databaseService(keyFor service: any DatabaseServiceProtocol) throws -> Connection.Key? { + func databaseService(keyFor service: any DatabaseServiceProtocol) throws -> Connection.Key { currentKey } @@ -184,16 +183,12 @@ extension DatabaseServiceTests { try stmt.step() } }) - currentKey = keyTwo - try service.reconnect() - try service.perform { connection in - let stmt = try connection.prepare( - sql: "SELECT COUNT(*) FROM Item", - options: [] - ) - try stmt.step() - #expect(connection.isAutocommit) - #expect(stmt.columnValue(at: 0) == 0) - } + let stmt = try connection.prepare( + sql: "SELECT COUNT(*) FROM Item", + options: [] + ) + try stmt.step() + #expect(connection.isAutocommit) + #expect(stmt.columnValue(at: 0) == 0) } } diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift index f116473..d4095a2 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: try .init(connection: connection), storage: .init()) + self.migrationService = .init(service: .init(connection: connection), storage: .init()) } @Test func addMigration() throws { From 5bbb722b20b5d9099221c6b3785d51abd3fc05a0 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Fri, 7 Nov 2025 20:38:09 +0200 Subject: [PATCH 5/7] Refactoring --- Package.swift | 14 +- .../Aliases/VersionRepresentable.swift | 35 ++ .../DataRaft/Classes/ConnectionService.swift | 204 +++++++++ .../DataRaft/Classes/DatabaseService.swift | 426 ++++++++---------- .../DataRaft/Classes/MigrationService.swift | 246 ++++++---- .../Classes/ModelDatabaseService.swift | 138 ++++++ .../DataRaft/Classes/RowDatabaseService.swift | 115 ----- .../DataRaft/Classes/UserVersionStorage.swift | 75 +-- Sources/DataRaft/Enums/MigrationError.swift | 32 +- .../Extensions/Notification+UserInfoKey.swift | 33 ++ .../Extensions/NotificationCenter.swift | 9 + .../ConnectionServiceKeyProvider.swift | 45 ++ .../Protocols/ConnectionServiceProtocol.swift | 56 +++ .../DatabaseServiceKeyProvider.swift | 53 --- .../Protocols/DatabaseServiceProtocol.swift | 76 ++-- .../Protocols/MigrationServiceProtocol.swift | 58 ++- .../RowDatabaseServiceProtocol.swift | 17 - .../Protocols/VersionRepresentable.swift | 33 -- .../DataRaft/Protocols/VersionStorage.swift | 98 ++-- .../DataRaft/Structures/BitPackVersion.swift | 3 +- Sources/DataRaft/Structures/Migration.swift | 67 +-- .../Classes/DatabaseServiceTests.swift | 19 +- .../Classes/MigrationServiceTests.swift | 10 +- Tests/DataRaftTests/Resources/empty.sql | 1 + Tests/DataRaftTests/Resources/migration_4.sql | 1 - .../Structures/MigrationTests.swift | 3 +- 26 files changed, 1097 insertions(+), 770 deletions(-) create mode 100644 Sources/DataRaft/Aliases/VersionRepresentable.swift create mode 100644 Sources/DataRaft/Classes/ConnectionService.swift create mode 100644 Sources/DataRaft/Classes/ModelDatabaseService.swift delete mode 100644 Sources/DataRaft/Classes/RowDatabaseService.swift create mode 100644 Sources/DataRaft/Extensions/Notification+UserInfoKey.swift create mode 100644 Sources/DataRaft/Extensions/NotificationCenter.swift create mode 100644 Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift create mode 100644 Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift delete mode 100644 Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift delete mode 100644 Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift delete mode 100644 Sources/DataRaft/Protocols/VersionRepresentable.swift create mode 100644 Tests/DataRaftTests/Resources/empty.sql delete mode 100644 Tests/DataRaftTests/Resources/migration_4.sql diff --git a/Package.swift b/Package.swift index 8ee30ed..9995664 100644 --- a/Package.swift +++ b/Package.swift @@ -16,14 +16,8 @@ let package = Package( ) ], dependencies: [ - .package( - url: "https://github.com/angd-dev/data-lite-core.git", - revision: "5c6942bd0b9636b5ac3e550453c07aac843e8416" - ), - .package( - url: "https://github.com/angd-dev/data-lite-coder.git", - revision: "5aec6ea5784dd5bd098bfa98036fbdc362a8931c" - ), + .package(url: "https://github.com/angd-dev/data-lite-core.git", from: "1.0.0"), + .package(url: "https://github.com/angd-dev/data-lite-coder.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ @@ -38,10 +32,10 @@ let package = Package( name: "DataRaftTests", dependencies: ["DataRaft"], resources: [ + .copy("Resources/empty.sql"), .copy("Resources/migration_1.sql"), .copy("Resources/migration_2.sql"), - .copy("Resources/migration_3.sql"), - .copy("Resources/migration_4.sql") + .copy("Resources/migration_3.sql") ] ) ] diff --git a/Sources/DataRaft/Aliases/VersionRepresentable.swift b/Sources/DataRaft/Aliases/VersionRepresentable.swift new file mode 100644 index 0000000..071cd4e --- /dev/null +++ b/Sources/DataRaft/Aliases/VersionRepresentable.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A type that describes a database schema version. +/// +/// ## Overview +/// +/// Types conforming to this alias can be compared, checked for equality, hashed, and safely used +/// across concurrent contexts. Such types are typically used to track and manage schema migrations. +/// +/// ## Conformance +/// +/// Conforming types must implement: +/// - `Equatable` — for equality checks +/// - `Comparable` — for ordering versions +/// - `Hashable` — for dictionary/set membership +/// - `Sendable` — for concurrency safety +/// +/// ## Usage +/// +/// Use this type alias when defining custom version types for use with ``VersionStorage``. +/// +/// ```swift +/// struct SemanticVersion: VersionRepresentable { +/// let major: Int +/// let minor: Int +/// let patch: Int +/// +/// static func < (lhs: Self, rhs: Self) -> Bool { +/// if lhs.major != rhs.major { return lhs.major < rhs.major } +/// if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } +/// return lhs.patch < rhs.patch +/// } +/// } +/// ``` +public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable diff --git a/Sources/DataRaft/Classes/ConnectionService.swift b/Sources/DataRaft/Classes/ConnectionService.swift new file mode 100644 index 0000000..fb8708d --- /dev/null +++ b/Sources/DataRaft/Classes/ConnectionService.swift @@ -0,0 +1,204 @@ +import Foundation +import DataLiteCore + +/// A base service responsible for establishing and maintaining a database connection. +/// +/// ## Overview +/// +/// `ConnectionService` provides a managed execution environment for database operations. It handles +/// connection creation, configuration, encryption key application, and optional reconnection based +/// on the associated key provider’s policy. +/// +/// This class guarantees thread-safe access by executing all operations within a dedicated dispatch +/// queue. Subclasses may extend it with additional behaviors, such as transaction management or +/// lifecycle event posting. +/// +/// ## Topics +/// +/// ### Creating a Service +/// +/// - ``ConnectionProvider`` +/// - ``ConnectionConfig`` +/// - ``init(provider:config:queue:)`` +/// - ``init(connection:config:queue:)`` +/// +/// ### Key Management +/// +/// - ``ConnectionServiceKeyProvider`` +/// - ``keyProvider`` +/// +/// ### Connection Lifecycle +/// +/// - ``setNeedsReconnect()`` +/// +/// ### Performing Operations +/// +/// - ``ConnectionServiceProtocol/Perform`` +/// - ``perform(_:)`` +open class ConnectionService: + ConnectionServiceProtocol, + @unchecked Sendable +{ + // MARK: - Typealiases + + /// A closure that creates a new database connection. + /// + /// Used for deferred connection creation. Encapsulates initialization logic, configuration, and + /// error handling when opening the database. + /// + /// - Returns: An initialized connection instance. + /// - Throws: An error if the connection cannot be created or configured. + public typealias ConnectionProvider = () throws -> ConnectionProtocol + + /// A closure that configures a newly created connection. + /// + /// Called after the connection is established and, if applicable, after the encryption key has + /// been applied. Use this closure to set PRAGMA options or perform additional initialization + /// logic. + /// + /// - Parameter connection: The newly created connection to configure. + /// - Throws: An error if configuration fails. + public typealias ConnectionConfig = (ConnectionProtocol) throws -> Void + + // MARK: - Properties + + private let provider: ConnectionProvider + private let config: ConnectionConfig? + private let queue: DispatchQueue + private let queueKey = DispatchSpecificKey() + + private var shouldReconnect: Bool { + keyProvider?.connectionService(shouldReconnect: self) ?? false + } + + private var needsReconnect: Bool = false + private var cachedConnection: ConnectionProtocol? + + private var connection: ConnectionProtocol { + get throws { + guard let cachedConnection, !needsReconnect else { + let connection = try connect() + cachedConnection = connection + needsReconnect = false + return connection + } + return cachedConnection + } + } + + /// The provider responsible for supplying encryption keys to the service. + /// + /// The key provider may determine whether reconnection is allowed and supply + /// the encryption key when the connection is established or restored. + public weak var keyProvider: ConnectionServiceKeyProvider? + + // MARK: - Inits + + /// Creates a new connection service. + /// + /// Configures an internal serial queue for thread-safe access to the database. The connection + /// itself is not created during initialization — it is established lazily on first use (for + /// example, inside ``perform(_:)``). + /// + /// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the + /// target of the internal queue. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established and + /// the encryption key is applied. + /// - queue: An optional target queue for the internal one. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.provider = provider + self.config = config + self.queue = .init(for: Self.self, qos: .utility) + self.queue.setSpecific(key: queueKey, value: ()) + if let queue = queue { + self.queue.setTarget(queue: queue) + } + } + + /// Creates a new connection service using an autoclosure-based provider. + /// + /// This initializer provides a convenient way to wrap an existing connection expression in an + /// autoclosure. The connection itself is not created during initialization — it is established + /// lazily on first use. + /// + /// - Parameters: + /// - provider: An autoclosure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established and + /// the encryption key is applied. + /// - queue: An optional target queue for the internal one. + public required convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.init(provider: provider, config: config, queue: queue) + } + + // MARK: - Connection Lifecycle + + /// Marks the service as requiring reconnection before the next operation. + /// + /// The reconnection behavior depends on the key provider’s implementation of + /// ``ConnectionServiceKeyProvider/connectionService(shouldReconnect:)``. If reconnection is + /// allowed, the next access to the connection will create and configure a new one. + /// + /// - Returns: `true` if the reconnection flag was set; otherwise, `false`. + @discardableResult + public func setNeedsReconnect() -> Bool { + switch DispatchQueue.getSpecific(key: queueKey) { + case .none: + return queue.sync { setNeedsReconnect() } + case .some: + guard shouldReconnect else { return false } + needsReconnect = true + return true + } + } + + // MARK: - Performing Operations + + /// Executes a closure within the context of a managed database connection. + /// + /// Runs the operation on the service’s internal queue and ensures that the connection is valid + /// before use. If the connection is unavailable or fails during execution, this method throws + /// an error. + /// + /// - Parameter closure: The operation to perform using the connection. + /// - Returns: The result produced by the closure. + /// - Throws: An error thrown by the closure or the connection. + public func perform(_ closure: Perform) throws -> T { + switch DispatchQueue.getSpecific(key: queueKey) { + case .none: try queue.sync { try closure(connection) } + case .some: try closure(connection) + } + } + + // MARK: - Internal Methods + + func connect() throws -> ConnectionProtocol { + let connection = try provider() + try applyKey(to: connection) + try config?(connection) + return connection + } + + func applyKey(to connection: ConnectionProtocol) throws { + guard let keyProvider = keyProvider else { return } + do { + let key = try keyProvider.connectionService(keyFor: self) + let sql = "SELECT count(*) FROM sqlite_master" + try connection.apply(key, name: nil) + try connection.execute(sql: sql) + } catch { + keyProvider.connectionService(self, didReceive: error) + throw error + } + } +} diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 82b4462..492ba1b 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -1,313 +1,255 @@ import Foundation -import DataLiteC import DataLiteCore +import DataLiteC -/// Base service for working with a database. +/// A base database service handling transactions and event notifications. /// -/// `DatabaseService` provides a unified interface for performing operations using a database -/// connection, with built-in support for transactions, reconnection, and optional encryption -/// key management. +/// ## Overview /// -/// 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. +/// `DatabaseService` provides a foundational layer for performing transactional database operations +/// within a thread-safe execution context. It automatically posts lifecycle notifications — such as +/// commit, rollback, and content changes — allowing observers to react to database updates in real +/// time. By default, it routes events through ``Foundation/NotificationCenter/database`` so that +/// clients can subscribe via a dedicated channel. This service is designed to be subclassed by +/// higher-level data managers that encapsulate domain logic while relying on consistent connection +/// and transaction handling. /// -/// The connection is established lazily on first use (e.g., within `perform`), not during -/// initialization. If a key provider is set, the key is applied as part of establishing or -/// restoring the connection. -/// -/// Below is an example of creating a service for managing notes: +/// ## Usage /// /// ```swift /// final class NoteService: DatabaseService { /// func insertNote(_ text: String) throws { -/// try perform { connection in -/// let stmt = try connection.prepare(sql: "INSERT INTO notes (text) VALUES (?)") +/// try perform(in: .deferred) { connection in +/// let sql = "INSERT INTO notes (text) VALUES (?)" +/// let stmt = try connection.prepare(sql: sql) /// try stmt.bind(text, at: 0) /// try stmt.step() /// } /// } -/// -/// func fetchNotes() throws -> [String] { -/// try perform { connection in -/// let stmt = try connection.prepare(sql: "SELECT text FROM notes") -/// var result: [String] = [] -/// while try stmt.step() { -/// if let text: String = stmt.columnValue(at: 0) { -/// result.append(text) -/// } -/// } -/// return result -/// } -/// } /// } /// -/// let connection = try Connection(location: .inMemory, options: .readwrite) +/// let connection = try Connection(location: .inMemory, options: []) /// let service = NoteService(connection: connection) -/// /// try service.insertNote("Hello, world!") -/// let notes = try service.fetchNotes() -/// print(notes) // ["Hello, world!"] /// ``` /// -/// ## Error Handling -/// -/// All operations are executed on an internal serial queue, ensuring thread safety. If a -/// decryption 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 uses it to obtain and apply an encryption key when -/// establishing or restoring the connection. If an error occurs while obtaining or applying the -/// key, the provider is notified through -/// ``DatabaseServiceKeyProvider/databaseService(_:didReceive:)``. -/// -/// ## Reconnection -/// -/// 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 /// -/// - ``ConnectionProvider`` -/// - ``ConnectionConfig`` -/// - ``init(provider:config:keyProvider:queue:)`` -/// - ``init(connection:config:keyProvider:queue:)`` +/// - ``ConnectionService/ConnectionProvider`` +/// - ``ConnectionService/ConnectionConfig`` +/// - ``init(provider:config:queue:center:)`` +/// - ``init(provider:config:queue:)`` /// -/// ### Key Management +/// ### Performing Operations /// -/// - ``DatabaseServiceKeyProvider`` -/// - ``keyProvider`` -/// -/// ### Database Operations -/// -/// - ``DatabaseServiceProtocol/Perform`` +/// - ``ConnectionServiceProtocol/Perform`` /// - ``perform(_:)`` /// - ``perform(in:closure:)`` -open class DatabaseService: DatabaseServiceProtocol, @unchecked Sendable { - // MARK: - Types - - /// A closure that creates a new database connection. - /// - /// `ConnectionProvider` is used for deferred connection creation. - /// It allows encapsulating initialization logic, configuration, and - /// error handling when opening the database. - /// - /// - Returns: An initialized `Connection` instance. - /// - Throws: An error if the connection cannot be created or configured. - public typealias ConnectionProvider = () throws -> Connection - - /// A closure used to configure a newly created connection. - /// - /// Called after the connection is established (and after key application if present). - /// Can be used to set PRAGMA options or perform other initialization logic. - /// - /// - Parameter connection: The newly created connection. - /// - Throws: Any error if configuration fails. - public typealias ConnectionConfig = (Connection) throws -> Void - +/// +/// ### Connection Delegate +/// +/// - ``connection(_:didUpdate:)`` +/// - ``connectionWillCommit(_:)`` +/// - ``connectionDidRollback(_:)`` +/// +/// ### Notifications +/// +/// - ``databaseDidChange`` +/// - ``databaseWillCommit`` +/// - ``databaseDidRollback`` +/// - ``databaseDidPerform`` +open class DatabaseService: + ConnectionService, + DatabaseServiceProtocol, + ConnectionDelegate, + @unchecked Sendable +{ // MARK: - Properties - private let provider: ConnectionProvider - private let config: ConnectionConfig? - private let queue: DispatchQueue - private let queueKey = DispatchSpecificKey() + private let center: NotificationCenter - private var cachedConnection: Connection? - private var connection: Connection { - get throws { - guard let cachedConnection else { - let connection = try connect() - cachedConnection = connection - return connection - } - return cachedConnection - } - } - - /// Encryption key provider. + /// Notification posted after the database content changes. /// - /// Used to obtain and apply a key when establishing or restoring a connection. The key is - /// requested on first access to the connection and on reconnection if needed. - public weak var keyProvider: DatabaseServiceKeyProvider? + /// Observers listen to this event to refresh cached data or update dependent components once + /// modifications are committed. The notification’s `userInfo` may include + /// ``Foundation/Notification/UserInfoKey/action`` describing the SQLite action. + public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange") + + /// Notification posted immediately before a transaction commits. + /// + /// Observers can perform validation or prepare for an upcoming state change while the + /// transaction is still in progress. + public static let databaseWillCommit = Notification.Name("DatabaseService.databaseWillCommit") + + /// Notification posted after a transaction rolls back. + /// + /// Observers use this event to revert in-memory state or reset caches that rely on pending + /// changes. + public static let databaseDidRollback = Notification.Name("DatabaseService.databaseDidRollback") + + /// Notification posted after any database operation completes, regardless of outcome. + /// + /// The service emits this event after finishing a `perform(_:)` block so observers can + /// synchronize state even when the operation is read-only or aborted. + /// + /// - Important: Confirm that the associated transaction was not rolled back before relying on + /// side effects. + public static let databaseDidPerform = Notification.Name("DatabaseService.databaseDidPerform") // MARK: - Inits - /// Creates a new database service. + /// Creates a database service that posts lifecycle events to the provided notification center. /// - /// Configures the internal serial queue for thread-safe access to the database. - /// The connection is **not** created during initialization. It is established - /// lazily on first use (for example, inside `perform`). - /// - /// 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 will be applied when the - /// connection is established or restored. + /// The underlying connection handling matches ``ConnectionService``; the connection is created + /// lazily and all work executes on the managed serial queue. /// /// - Parameters: - /// - provider: A closure that returns a new connection. - /// - config: An optional configuration closure called after the connection - /// is created (and after key application if present). - /// - keyProvider: An optional encryption key provider. - /// - queue: An optional target queue for the internal one. + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established and + /// the encryption key is applied. + /// - queue: An optional target queue for the internal serial queue. + /// - center: A notification center for posting database events. public init( provider: @escaping ConnectionProvider, config: ConnectionConfig? = nil, - keyProvider: DatabaseServiceKeyProvider? = nil, + queue: DispatchQueue? = nil, + center: NotificationCenter + ) { + self.center = center + super.init(provider: provider, config: config, queue: queue) + } + + /// Creates a database service that posts lifecycle events to the shared database notification + /// center. + /// + /// The connection is established lazily on first access and all work executes on the internal + /// queue defined in ``ConnectionService``. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established and + /// the encryption key is applied. + /// - queue: An optional target queue for the internal serial queue. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, queue: DispatchQueue? = nil ) { - self.provider = provider - self.config = config - self.keyProvider = keyProvider - self.queue = .init(for: Self.self, qos: .utility) - self.queue.setSpecific(key: queueKey, value: ()) - if let queue = queue { - self.queue.setTarget(queue: queue) + self.center = .database + super.init(provider: provider, config: config, queue: queue) + } + + // MARK: - Performing Operations + + /// Executes a closure with a managed database connection and posts a completion notification. + /// + /// The override mirrors ``ConnectionService/perform(_:)`` for queue-confined execution while + /// ensuring ``DatabaseService/databaseDidPerform`` is delivered after the closure completes. + /// + /// - Parameter closure: The operation to execute using the open connection. + /// - Returns: The value returned by the closure. + /// - Throws: Errors thrown by the closure or underlying connection. + public override func perform(_ closure: Perform) throws -> T { + try super.perform { connection in + defer { center.post(name: Self.databaseDidPerform, object: self) } + return try closure(connection) } } - /// Creates a new database service. + /// Executes a closure inside a transaction when the connection operates in autocommit mode. /// - /// The connection is created lazily on first use. If a `keyProvider` is set, - /// the key will be applied when the connection is established. + /// The method begins the requested `TransactionType`, runs the closure, and commits the + /// transaction on success. Failures trigger a rollback. If the SQLite engine reports + /// `SQLITE_NOTADB` and the key provider allows reconnection, the service re-establishes the + /// connection and retries the closure once, mirroring the behavior described in + /// ``DatabaseServiceProtocol``. /// /// - Parameters: - /// - provider: An expression that creates a new connection. - /// - config: An optional configuration closure called after the connection - /// is created (and after key application if present). - /// - keyProvider: An optional encryption key provider. - /// - queue: An optional target queue for the internal one. - public convenience init( - connection provider: @escaping @autoclosure ConnectionProvider, - config: ConnectionConfig? = nil, - keyProvider: DatabaseServiceKeyProvider? = nil, - queue: DispatchQueue? = nil - ) { - self.init( - provider: provider, - config: config, - keyProvider: keyProvider, - queue: queue - ) - } - - // MARK: - Methods - - /// Executes a closure with the current connection. - /// - /// Ensures thread-safe access by running the closure on the internal serial queue. - /// The connection is created lazily if needed. - /// - /// - Parameter closure: A closure that takes the active connection. + /// - transaction: The type of transaction to start (for example, `.deferred`). + /// - closure: The work to run while the transaction is active. /// - Returns: The value returned by the closure. - /// - Throws: An error if the connection cannot be created or if the closure throws. - final public func perform(_ closure: Perform) throws -> T { - try withConnection(closure) - } - - /// Executes a closure inside a transaction if the connection is in autocommit mode. + /// - Throws: Errors from the closure, transaction handling, or connection management. /// - /// 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 `Connection.Error` with code `SQLITE_NOTADB` and reconnection - /// is allowed, the service attempts to create a new connection, reapply the key, and - /// retries the transaction block once. If the second attempt fails or reconnection - /// is disallowed, the error is propagated without further retries. - /// - /// 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 start. - /// - closure: A closure that takes the active connection and returns a result. - /// - Returns: The value returned by the closure. - /// - Throws: Errors from connection creation, key application, configuration, - /// transaction management, or from the closure itself. - /// - Important: The closure may be executed more than once. Ensure it is idempotent. - final public func perform( + /// - Important: The closure may be executed more than once if a reconnection occurs. Ensure it + /// performs only database operations and does not produce external side effects (such as + /// sending network requests or posting notifications). + public func perform( in transaction: TransactionType, closure: Perform ) throws -> T { - try withConnection { connection in - if connection.isAutocommit { - do { - try connection.beginTransaction(transaction) - let result = try closure(connection) - try connection.commitTransaction() - return result - } catch { + try perform { connection in + guard connection.isAutocommit else { + return try closure(connection) + } + + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + if !connection.isAutocommit { try connection.rollbackTransaction() - guard let error = error as? Connection.Error, - error.code == SQLITE_NOTADB, - shouldReconnect - else { throw error } - - try reconnect() - - return try withConnection { connection in - do { - try connection.beginTransaction(transaction) - let result = try closure(connection) - try connection.commitTransaction() - return result - } catch { + } + + guard + let error = error as? SQLiteError, + error.code == SQLITE_NOTADB, + setNeedsReconnect() + else { + throw error + } + + return try perform { connection in + do { + try connection.beginTransaction(transaction) + let result = try closure(connection) + try connection.commitTransaction() + return result + } catch { + if !connection.isAutocommit { try connection.rollbackTransaction() - throw error } + throw error } } - } else { - return try closure(connection) } } } -} - -// MARK: - Private - -private extension DatabaseService { - var shouldReconnect: Bool { - keyProvider?.databaseService(shouldReconnect: self) ?? false + + // MARK: - ConnectionDelegate + + /// Posts ``DatabaseService/databaseDidChange`` when the database content updates. + /// + /// - Parameters: + /// - connection: The connection that performed the change. + /// - action: The SQLite action describing the modification. + public func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) { + let userInfo = [Notification.UserInfoKey.action: action] + center.post(name: Self.databaseDidChange, object: self, userInfo: userInfo) } - func withConnection(_ closure: Perform) throws -> T { - switch DispatchQueue.getSpecific(key: queueKey) { - case .none: try queue.asyncAndWait { try closure(connection) } - case .some: try closure(connection) - } + /// Posts ``DatabaseService/databaseWillCommit`` before a transaction commits. + /// + /// - Parameter connection: The connection preparing to commit. + public func connectionWillCommit(_ connection: any ConnectionProtocol) throws { + center.post(name: Self.databaseWillCommit, object: self) } - func reconnect() throws { - cachedConnection = try connect() + /// Posts ``DatabaseService/databaseDidRollback`` after a transaction rollback. + /// + /// - Parameter connection: The connection that rolled back. + public func connectionDidRollback(_ connection: any ConnectionProtocol) { + center.post(name: Self.databaseDidRollback, object: self) } - func connect() throws -> Connection { - let connection = try provider() - try applyKey(to: connection) - try config?(connection) + // MARK: - Internal Methods + + override func connect() throws -> any ConnectionProtocol { + let connection = try super.connect() + connection.add(delegate: self) return connection } - - func applyKey(to connection: Connection) throws { - guard let keyProvider = keyProvider else { return } - do { - 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) - throw error - } - } } diff --git a/Sources/DataRaft/Classes/MigrationService.swift b/Sources/DataRaft/Classes/MigrationService.swift index 5c564da..840b06a 100644 --- a/Sources/DataRaft/Classes/MigrationService.swift +++ b/Sources/DataRaft/Classes/MigrationService.swift @@ -1,135 +1,195 @@ import Foundation import DataLiteCore -/// Thread-safe service for executing ordered database schema migrations. +#if os(Windows) + import WinSDK +#endif + +/// A service that executes ordered database schema migrations. /// -/// `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. +/// ## Overview /// -/// The service is generic over: -/// - `Service`: a database service conforming to ``DatabaseServiceProtocol`` -/// - `Storage`: a version storage conforming to ``VersionStorage`` +/// This class manages migration registration and applies them sequentially to update the database +/// schema. Each migration corresponds to a specific version and runs only once, ensuring that +/// schema upgrades are applied in a consistent, deterministic way. /// -/// Migrations are identified by version and script URL. Both must be unique -/// across all registered migrations. +/// Migrations are executed within an exclusive transaction — if any step fails, the entire process +/// is rolled back, leaving the database unchanged. /// -/// 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. +/// `MigrationService` coordinates the migration process by: +/// - Managing a registry of unique migrations. +/// - Reading and writing the current schema version through a ``VersionStorage`` implementation. +/// - Executing SQL scripts in ascending version order. /// -/// This type is safe to use from multiple threads. +/// It is safe for concurrent use. Internally, it uses a POSIX mutex to ensure thread-safe +/// registration and execution. +/// +/// ## Usage /// /// ```swift /// let connection = try Connection(location: .inMemory, options: .readwrite) -/// let storage = UserVersionStorage() -/// let service = MigrationService(service: connectionService, storage: storage) +/// let storage = UserVersionStorage() +/// let service = MigrationService(provider: { connection }, 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.0.0", byResource: "v1_0_0.sql")!) +/// try service.add(Migration(version: "1.0.1", byResource: "v1_0_1.sql")!) /// try service.migrate() /// ``` /// -/// ### Custom Versions and Storage +/// ## Topics /// -/// 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.). +/// ### Initializers +/// +/// - ``init(provider:config:queue:storage:)`` +/// - ``init(provider:config:queue:)`` +/// +/// ### Migration Management +/// +/// - ``add(_:)`` +/// - ``migrate()`` public final class MigrationService< - Service: DatabaseServiceProtocol, Storage: VersionStorage >: + ConnectionService, MigrationServiceProtocol, @unchecked Sendable { - /// Schema version type used for migration ordering. + // MARK: - Typealiases + + /// The type representing schema version ordering. public typealias Version = Storage.Version - private let service: Service + // MARK: - Properties + private let storage: Storage - private var mutex = pthread_mutex_t() private var migrations = Set>() - /// Encryption key provider delegated to the underlying database service. - public weak var keyProvider: DatabaseServiceKeyProvider? { - get { service.keyProvider } - set { service.keyProvider = newValue } - } + #if os(Windows) + private var mutex = SRWLOCK() + #else + private var mutex = pthread_mutex_t() + #endif - /// Creates a migration service with the given database service and storage. + // MARK: - Inits + + /// Creates a migration service with a specified connection configuration and version storage. /// /// - Parameters: - /// - service: Database service used to execute migrations. - /// - storage: Version storage for reading and writing schema version. + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established. + /// - queue: An optional target queue for internal database operations. + /// - storage: The version storage responsible for reading and writing schema version data. public init( - service: Service, + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, storage: Storage ) { - self.service = service self.storage = storage - pthread_mutex_init(&mutex, nil) + super.init(provider: provider, config: config, queue: queue) + + #if os(Windows) + InitializeSRWLock(&mutex) + #else + pthread_mutex_init(&mutex, nil) + #endif + } + + /// Creates a migration service using the default version storage. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: An optional configuration closure called after the connection is established. + /// - queue: An optional target queue for internal database operations. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.storage = .init() + super.init(provider: provider, config: config, queue: queue) + + #if os(Windows) + InitializeSRWLock(&mutex) + #else + pthread_mutex_init(&mutex, nil) + #endif } deinit { - pthread_mutex_destroy(&mutex) + #if !os(Windows) + pthread_mutex_destroy(&mutex) + #endif } + // MARK: - Unsupported + + @available(*, unavailable) + public override func setNeedsReconnect() -> Bool { + fatalError("Reconnection is not supported for MigrationService.") + } + + @available(*, unavailable) + public override func perform(_ closure: Perform) throws -> T { + fatalError("Direct perform is not supported for MigrationService.") + } + + // MARK: - Migration Management + /// Registers a new migration, ensuring version and script URL uniqueness. /// /// - Parameter migration: The migration to register. - /// - Throws: ``MigrationError/duplicateMigration(_:)`` if the migration's - /// version or script URL is already registered. + /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same 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 { + #if os(Windows) + AcquireSRWLockExclusive(&mutex) + defer { ReleaseSRWLockExclusive(&mutex) } + #else + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } + #endif + + guard + !migrations.contains(where: { + $0.version == migration.version + || $0.scriptURL == migration.scriptURL + }) + else { throw .duplicateMigration(migration) } + migrations.insert(migration) } - /// Executes all pending migrations inside a single exclusive transaction. + /// Executes all pending migrations in ascending version order. /// - /// 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. + /// The service retrieves the current version from ``VersionStorage``, selects migrations with + /// higher versions, sorts them, and executes their scripts inside an exclusive transaction. /// - /// If a script is empty or execution fails, the process aborts and the transaction - /// is rolled back, leaving the database unchanged. - /// - /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a script is empty. - /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if execution or version - /// update fails. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration fails to execute or the + /// version update cannot be persisted. public func migrate() throws(MigrationError) { - pthread_mutex_lock(&mutex) - defer { pthread_mutex_unlock(&mutex) } + #if os(Windows) + AcquireSRWLockExclusive(&mutex) + defer { ReleaseSRWLockExclusive(&mutex) } + #else + pthread_mutex_lock(&mutex) + defer { pthread_mutex_unlock(&mutex) } + #endif + do { - try service.perform(in: .exclusive) { connection in - try storage.prepare(connection) - let version = try storage.getVersion(connection) - let migrations = migrations - .filter { $0.version > version } - .sorted { $0.version < $1.version } - - for migration in migrations { - let script = try migration.script - guard !script.isEmpty else { - throw MigrationError.emptyMigrationScript(migration) + try super.perform { connection in + do { + try connection.beginTransaction(.exclusive) + try migrate(with: connection) + try connection.commitTransaction() + } catch { + if !connection.isAutocommit { + try connection.rollbackTransaction() } - do { - try connection.execute(sql: script) - } catch { - throw MigrationError.migrationFailed(migration, error) - } - } - - if let version = migrations.last?.version { - try storage.setVersion(connection, version) + throw error } } } catch let error as MigrationError { @@ -139,3 +199,31 @@ public final class MigrationService< } } } + +// MARK: - Private + +private extension MigrationService { + func migrate(with connection: ConnectionProtocol) throws { + try storage.prepare(connection) + let version = try storage.getVersion(connection) + let migrations = migrations + .filter { $0.version > version } + .sorted { $0.version < $1.version } + + for migration in migrations { + let script = try migration.script + guard !script.isEmpty else { + throw MigrationError.emptyMigrationScript(migration) + } + do { + try connection.execute(sql: script) + } catch { + throw MigrationError.migrationFailed(migration, error) + } + } + + if let version = migrations.last?.version { + try storage.setVersion(connection, version) + } + } +} diff --git a/Sources/DataRaft/Classes/ModelDatabaseService.swift b/Sources/DataRaft/Classes/ModelDatabaseService.swift new file mode 100644 index 0000000..cb1b441 --- /dev/null +++ b/Sources/DataRaft/Classes/ModelDatabaseService.swift @@ -0,0 +1,138 @@ +import Foundation +import DataLiteCoder + +/// A database service that provides model encoding and decoding support. +/// +/// ## Overview +/// +/// `ModelDatabaseService` extends ``DatabaseService`` by integrating `RowEncoder` and `RowDecoder` +/// to simplify model-based interactions with the database. Subclasses can encode Swift types into +/// SQLite rows and decode query results back into strongly typed models. +/// +/// This enables a clean, type-safe persistence layer for applications that use Codable or custom +/// encodable/decodable types. +/// +/// `ModelDatabaseService` serves as a foundation for higher-level model repositories and services. +/// It inherits all transactional and thread-safe behavior from ``DatabaseService`` while adding +/// automatic model serialization. +/// +/// ## Usage +/// +/// ```swift +/// struct User: Codable { +/// let id: Int +/// let name: String +/// } +/// +/// final class UserService: ModelDatabaseService, @unchecked Sendable { +/// func fetchUser() throws -> User? { +/// try perform(in: .deferred) { connection in +/// let stmt = try connection.prepare(sql: "SELECT * FROM users") +/// guard try stmt.step(), let row = stmt.currentRow() else { +/// return nil +/// } +/// return try decoder.decode(User.self, from: row) +/// } +/// } +/// +/// func insertUser(_ user: User) throws { +/// try perform(in: .immediate) { connection in +/// let row = try encoder.encode(user) +/// let columns = row.columns.joined(separator: ", ") +/// let placeholders = row.namedParameters.joined(separator: ", ") +/// let sql = "INSERT INTO users (\(columns)) VALUES (\(placeholders))" +/// let stmt = try connection.prepare(sql: sql) +/// try stmt.execute([row]) +/// } +/// } +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Properties +/// +/// - ``encoder`` +/// - ``decoder`` +/// +/// ### Initializers +/// +/// - ``init(provider:config:queue:center:encoder:decoder:)`` +/// - ``init(provider:config:queue:)`` +/// - ``init(connection:config:queue:)`` +open class ModelDatabaseService: DatabaseService, @unchecked Sendable { + // MARK: - Properties + + /// The encoder used to serialize models into row representations. + public let encoder: RowEncoder + + /// The decoder used to deserialize database rows into model instances. + public let decoder: RowDecoder + + // MARK: - Inits + + /// Creates a model-aware database service. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: Optional configuration for the connection. + /// - queue: The dispatch queue used for serializing database operations. + /// - center: The notification center used for database events. Defaults to `.database`. + /// - encoder: The encoder for converting models into SQLite rows. + /// - decoder: The decoder for converting rows back into model instances. + public init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil, + center: NotificationCenter = .database, + encoder: RowEncoder, + decoder: RowDecoder + ) { + self.encoder = encoder + self.decoder = decoder + super.init( + provider: provider, + config: config, + queue: queue, + center: center + ) + } + + /// Creates a model-aware database service using default encoder and decoder instances. + /// + /// - Parameters: + /// - provider: A closure that returns a new database connection. + /// - config: Optional configuration for the connection. + /// - queue: The dispatch queue used for serializing database operations. + public required init( + provider: @escaping ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.encoder = .init() + self.decoder = .init() + super.init( + provider: provider, + config: config, + queue: queue + ) + } + + /// Creates a model-aware database service from a connection autoclosure. + /// + /// - Parameters: + /// - provider: A connection autoclosure that returns a database connection. + /// - config: Optional configuration for the connection. + /// - queue: The dispatch queue used for serializing database operations. + public required convenience init( + connection provider: @escaping @autoclosure ConnectionProvider, + config: ConnectionConfig? = nil, + queue: DispatchQueue? = nil + ) { + self.init( + provider: provider, + config: config, + queue: queue + ) + } +} diff --git a/Sources/DataRaft/Classes/RowDatabaseService.swift b/Sources/DataRaft/Classes/RowDatabaseService.swift deleted file mode 100644 index ed4a694..0000000 --- a/Sources/DataRaft/Classes/RowDatabaseService.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation -import DataLiteCore -import DataLiteCoder - -/// A database service that provides built-in row encoding and decoding. -/// -/// `RowDatabaseService` extends `DatabaseService` by adding support for -/// value serialization using `RowEncoder` and deserialization using `RowDecoder`. -/// -/// This enables subclasses to perform type-safe operations on models -/// encoded from or decoded into SQLite row representations. -/// -/// For example, a concrete service might define model-aware fetch or insert methods: -/// -/// ```swift -/// struct User: Codable { -/// let id: Int -/// let name: String -/// } -/// -/// final class UserService: RowDatabaseService { -/// func fetchUsers() throws -> [User] { -/// try perform(in: .deferred) { connection in -/// let stmt = try connection.prepare(sql: "SELECT * FROM users") -/// let rows = try stmt.execute() -/// return try decoder.decode([User].self, from: rows) -/// } -/// } -/// -/// func insertUser(_ user: User) throws { -/// try perform(in: .deferred) { connection in -/// let row = try encoder.encode(user) -/// let columns = row.columns.joined(separator: ", ") -/// let parameters = row.namedParameters.joined(separator: ", ") -/// let stmt = try connection.prepare( -/// sql: "INSERT INTO users (\(columns)) VALUES (\(parameters))" -/// ) -/// try stmt.execute(rows: [row]) -/// } -/// } -/// } -/// ``` -/// -/// `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, - @unchecked Sendable -{ - // MARK: - Properties - - /// The encoder used to serialize values into row representations. - public let encoder: RowEncoder - - /// The decoder used to deserialize row values into strongly typed models. - public let decoder: RowDecoder - - // MARK: - Inits - - /// Creates a new `RowDatabaseService`. - /// - /// This initializer accepts a closure that supplies the database connection. If no encoder - /// or decoder is provided, default instances are used. - /// - /// - Parameters: - /// - provider: A closure that returns a `Connection` instance. May throw an error. - /// - encoder: The encoder used to serialize models into SQLite-compatible rows. - /// Defaults to a new encoder. - /// - decoder: The decoder used to deserialize SQLite rows into typed models. - /// Defaults to a new decoder. - /// - queue: An optional dispatch queue used for serialization. If `nil`, an internal - /// serial queue with `.utility` QoS is created. - /// - Throws: Any error thrown by the connection provider. - public convenience init( - connection provider: @escaping @autoclosure ConnectionProvider, - encoder: RowEncoder = RowEncoder(), - decoder: RowDecoder = RowDecoder(), - queue: DispatchQueue? = nil - ) { - self.init( - provider: provider, - encoder: encoder, - decoder: decoder, - queue: queue - ) - } - - /// Designated initializer for `RowDatabaseService`. - /// - /// Initializes a new instance with the specified connection provider, encoder, decoder, - /// and an optional dispatch queue for synchronization. - /// - /// - Parameters: - /// - provider: A closure that returns a `Connection` instance. May throw an error. - /// - encoder: A custom `RowEncoder` used for encoding model data. Defaults to a new encoder. - /// - decoder: A custom `RowDecoder` used for decoding database rows. Defaults to a new decoder. - /// - queue: An optional dispatch queue for serializing access to the database connection. - /// If `nil`, a default internal serial queue with `.utility` QoS is used. - /// - Throws: Any error thrown by the connection provider. - public init( - provider: @escaping ConnectionProvider, - encoder: RowEncoder = RowEncoder(), - decoder: RowDecoder = RowDecoder(), - queue: DispatchQueue? = nil - ) { - self.encoder = encoder - self.decoder = decoder - super.init( - provider: provider, - queue: queue - ) - } -} diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift index 1fdad7c..5b051f9 100644 --- a/Sources/DataRaft/Classes/UserVersionStorage.swift +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -1,45 +1,57 @@ import Foundation import DataLiteCore -/// A database version storage that uses the `user_version` field. +/// A version storage that persists schema versions in SQLite’s `user_version` field. /// -/// This class implements ``VersionStorage`` by storing version information -/// in the SQLite `PRAGMA user_version` field. It provides a lightweight, -/// type-safe way to persist versioning data in a database. +/// ## Overview /// -/// The generic `Version` type must conform to both ``VersionRepresentable`` -/// and `RawRepresentable`, where `RawValue == UInt32`. This allows -/// converting between stored integer values and semantic version types -/// defined by the application. +/// `UserVersionStorage` provides a lightweight, type-safe implementation of ``VersionStorage`` that +/// stores version data using the SQLite `PRAGMA user_version` mechanism. This approach is simple, +/// efficient, and requires no additional tables. +/// +/// The generic ``Version`` type must conform to both ``VersionRepresentable`` and +/// `RawRepresentable`, with `RawValue == UInt32`. This enables conversion between stored integer +/// values and the application’s semantic version type. +/// +/// ## Topics +/// +/// ### Errors +/// +/// - ``Error`` +/// +/// ### Instance Methods +/// +/// - ``getVersion(_:)`` +/// - ``setVersion(_:_:)`` public final class UserVersionStorage< Version: VersionRepresentable & RawRepresentable >: Sendable, VersionStorage where Version.RawValue == UInt32 { - /// Errors related to reading or decoding the version. + + /// Errors related to reading or decoding the stored version. public enum Error: Swift.Error { - /// The stored `user_version` could not be decoded into a valid `Version` case. + /// The stored `user_version` value could not be decoded into a valid ``Version``. + /// + /// - Parameter value: The invalid raw `UInt32` value. case invalidStoredVersion(UInt32) } // MARK: - Inits - /// Creates a new user version storage instance. + /// Creates a new instance of user version storage. public init() {} - // MARK: - Methods + // MARK: - Version Management - /// Returns the current version stored in the `user_version` field. + /// Returns the current schema version stored in the `user_version` field. /// - /// This method reads the `PRAGMA user_version` value and attempts to - /// decode it into a valid `Version` value. If the stored value is not - /// recognized, it throws an error. + /// Reads the `PRAGMA user_version` value and attempts to decode it into a valid ``Version``. + /// If decoding fails, this method throws an error. /// - /// - Parameter connection: The database connection. - /// - Returns: A decoded version value of type `Version`. - /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value - /// cannot be mapped to a valid `Version` instance. - public func getVersion( - _ connection: Connection - ) throws -> Version { + /// - Parameter connection: The active database connection. + /// - Returns: The decoded version value. + /// - Throws: ``Error/invalidStoredVersion(_:)`` if the stored value cannot + /// be mapped to a valid version case. + public func getVersion(_ connection: ConnectionProtocol) throws -> Version { let raw = UInt32(bitPattern: connection.userVersion) guard let version = Version(rawValue: raw) else { throw Error.invalidStoredVersion(raw) @@ -47,20 +59,15 @@ public final class UserVersionStorage< return version } - /// Stores the given version in the `user_version` field. + /// Stores the specified schema version in the `user_version` field. /// - /// This method updates the `PRAGMA user_version` field - /// with the raw `UInt32` value of the provided `Version`. + /// Updates the SQLite `PRAGMA user_version` value with the raw `UInt32` representation of the + /// provided ``Version``. /// /// - Parameters: - /// - connection: The database connection. + /// - connection: The active database connection. /// - version: The version to store. - public func setVersion( - _ connection: Connection, - _ version: Version - ) throws { - connection.userVersion = .init( - bitPattern: version.rawValue - ) + public func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { + connection.userVersion = .init(bitPattern: version.rawValue) } } diff --git a/Sources/DataRaft/Enums/MigrationError.swift b/Sources/DataRaft/Enums/MigrationError.swift index 6fcad25..21f1b09 100644 --- a/Sources/DataRaft/Enums/MigrationError.swift +++ b/Sources/DataRaft/Enums/MigrationError.swift @@ -1,14 +1,34 @@ import Foundation import DataLiteCore -/// Errors that may occur during migration registration or execution. +/// Errors that can occur during database migration registration or execution. +/// +/// ## Overview +/// +/// These errors indicate problems such as duplicate migrations, failed execution, or empty +/// migration scripts. +/// +/// ## Topics +/// +/// ### Error Cases +/// - ``duplicateMigration(_:)`` +/// - ``emptyMigrationScript(_:)`` +/// - ``migrationFailed(_:_:)`` public enum MigrationError: Error { - /// A migration with the same version or script URL was already registered. + /// Indicates that a migration with the same version or script URL has already been registered. + /// + /// - Parameter migration: The duplicate migration instance. case duplicateMigration(Migration) - /// Migration execution failed, with optional reference to the failed migration. - case migrationFailed(Migration?, Error) - - /// The migration script is empty. + /// Indicates that the migration script is empty. + /// + /// - Parameter migration: The migration whose script is empty. case emptyMigrationScript(Migration) + + /// Indicates that migration execution failed. + /// + /// - Parameters: + /// - migration: The migration that failed, if available. + /// - error: The underlying error that caused the failure. + case migrationFailed(Migration?, Error) } diff --git a/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift b/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift new file mode 100644 index 0000000..f682447 --- /dev/null +++ b/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift @@ -0,0 +1,33 @@ +import Foundation + +extension Notification { + /// A strongly typed key used to access values in a notification’s user info dictionary. + /// + /// ## Overview + /// + /// `UserInfoKey` provides type safety when working with `Notification.userInfo`, replacing raw + /// string literals with well-defined constants. This helps prevent typos and improves + /// discoverability in database- or system-related notifications. + /// + /// ## Topics + /// + /// ### Keys + /// + /// - ``action`` + public struct UserInfoKey: RawRepresentable, Hashable, Sendable { + /// The raw string value of the key. + public let rawValue: String + + /// The key used to store the action associated with a notification. + public static let action = Self(rawValue: "action") + + /// Creates a user info key from the provided raw string value. + /// + /// Returns `nil` if the raw value is invalid. + /// + /// - Parameter rawValue: The raw string value of the key. + public init?(rawValue: String) { + self.rawValue = rawValue + } + } +} diff --git a/Sources/DataRaft/Extensions/NotificationCenter.swift b/Sources/DataRaft/Extensions/NotificationCenter.swift new file mode 100644 index 0000000..6f5a0a2 --- /dev/null +++ b/Sources/DataRaft/Extensions/NotificationCenter.swift @@ -0,0 +1,9 @@ +import Foundation + +public extension NotificationCenter { + /// The notification center dedicated to database events. + /// + /// Use this instance to post and observe notifications related to database lifecycle and + /// operations instead of using the shared `NotificationCenter.default`. + static let database = NotificationCenter() +} diff --git a/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift b/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift new file mode 100644 index 0000000..2688b9c --- /dev/null +++ b/Sources/DataRaft/Protocols/ConnectionServiceKeyProvider.swift @@ -0,0 +1,45 @@ +import Foundation +import DataLiteCore + +/// A type that provides encryption keys to a database connection service. +/// +/// ## Overview +/// +/// This type manages how encryption keys are obtained and applied when establishing or restoring a +/// connection. Implementations can use static, dynamic, hardware-backed, or biometric key sources. +/// +/// - The service requests a key when establishing or restoring a connection. +/// - If decryption fails, the service may ask whether it should attempt to reconnect. +/// - If applying a key fails (for example, the key is invalid or ``connectionService(keyFor:)`` +/// throws), the error is reported through ``connectionService(_:didReceive:)``. +/// +/// - Important: The provider does not receive general database errors. +/// +/// ## Topics +/// +/// ### Providing Keys and Handling Errors +/// +/// - ``connectionService(keyFor:)`` +/// - ``connectionService(shouldReconnect:)`` +/// - ``connectionService(_:didReceive:)`` +public protocol ConnectionServiceKeyProvider: AnyObject, Sendable { + /// Returns the encryption key for the specified database service. + /// + /// - Parameter service: The service requesting the key. + /// - Returns: The encryption key. + /// - Throws: An error if the key cannot be retrieved. + func connectionService(keyFor service: ConnectionServiceProtocol) throws -> Connection.Key + + /// Indicates whether the service should attempt to reconnect if applying the key fails. + /// + /// - Parameter service: The database service. + /// - Returns: `true` to attempt reconnection. Defaults to `false`. + func connectionService(shouldReconnect service: ConnectionServiceProtocol) -> Bool + + /// Notifies the provider of an error that occurred during key retrieval or application. + /// + /// - Parameters: + /// - service: The database service reporting the error. + /// - error: The error encountered during key retrieval or application. + func connectionService(_ service: ConnectionServiceProtocol, didReceive error: Error) +} diff --git a/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift b/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift new file mode 100644 index 0000000..f69774b --- /dev/null +++ b/Sources/DataRaft/Protocols/ConnectionServiceProtocol.swift @@ -0,0 +1,56 @@ +import Foundation +import DataLiteCore + +/// A type that manages the lifecycle of a database connection. +/// +/// ## Overview +/// +/// Conforming types implement the mechanisms required to open, configure, reconnect, and safelyuse +/// a database connection across multiple threads or tasks. This abstraction allows higher-level +/// services to execute operations without dealing with low-level connection handling. +/// +/// ## Topics +/// +/// ### Key Management +/// +/// - ``ConnectionServiceKeyProvider`` +/// - ``keyProvider`` +/// +/// ### Connection Lifecycle +/// +/// - ``setNeedsReconnect()`` +/// +/// ### Performing Operations +/// +/// - ``Perform`` +/// - ``perform(_:)`` +public protocol ConnectionServiceProtocol: AnyObject, Sendable { + /// A closure type that performs an operation using an active database connection. + /// + /// - Parameter connection: The active database connection used for the operation. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown by the closure or connection layer. + typealias Perform = (ConnectionProtocol) throws -> T + + /// The provider responsible for supplying encryption keys to the service. + var keyProvider: ConnectionServiceKeyProvider? { get set } + + /// Marks the service as requiring reconnection before the next operation. + /// + /// The reconnection behavior depends on the key provider’s implementation of + /// ``ConnectionServiceKeyProvider/connectionService(shouldReconnect:)``. + /// + /// - Returns: `true` if the reconnection flag was set; otherwise, `false`. + @discardableResult + func setNeedsReconnect() -> Bool + + /// Executes a closure within the context of an active database connection. + /// + /// Implementations ensure that a valid connection is available before executing the operation. + /// If the connection is not available or fails, this method throws an error. + /// + /// - Parameter closure: The operation to perform using the connection. + /// - Returns: The result produced by the closure. + /// - Throws: Any error thrown by the closure or the underlying connection. + func perform(_ closure: Perform) throws -> T +} diff --git a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift b/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift deleted file mode 100644 index e59fc0c..0000000 --- a/Sources/DataRaft/Protocols/DatabaseServiceKeyProvider.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import DataLiteCore - -/// A protocol for providing encryption keys to a database service. -/// -/// `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 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:)``. -/// -/// - Important: The provider does not receive notifications about general database errors. -/// -/// ## Topics -/// -/// ### Instance Methods -/// -/// - ``databaseService(keyFor:)`` -/// - ``databaseService(shouldReconnect:)`` -/// - ``databaseService(_:didReceive:)`` -public protocol DatabaseServiceKeyProvider: AnyObject, Sendable { - /// Returns the encryption key for the specified database service. - /// - /// This method must either return a valid encryption key or throw an error if - /// the key cannot be retrieved. - /// - /// - Parameter service: The service requesting the key. - /// - Returns: The encryption key. - /// - Throws: An error if the key cannot be retrieved. - func databaseService(keyFor service: DatabaseServiceProtocol) throws -> Connection.Key - - /// Indicates whether the service should attempt to reconnect if applying the key fails. - /// - /// - 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: DatabaseServiceProtocol, didReceive error: Error) -} - -public extension DatabaseServiceKeyProvider { - 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 6066927..21fc8bc 100644 --- a/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/DatabaseServiceProtocol.swift @@ -1,67 +1,45 @@ import Foundation import DataLiteCore -/// A protocol for a database service. +/// A type that extends connection management with transactional database operations. /// -/// `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. +/// ## Overview /// -/// This enables building safe and extensible service layers on top of -/// a database. +/// This type builds on ``ConnectionServiceProtocol`` by adding the ability to execute closures +/// within explicit transactions. Conforming types manage transaction boundaries and ensure that all +/// operations within a transaction are committed or rolled back consistently. /// /// ## Topics /// -/// ### Key Management +/// ### Performing Operations /// -/// - ``DatabaseServiceKeyProvider`` -/// - ``keyProvider`` -/// -/// ### Database Operations -/// -/// - ``Perform`` -/// - ``perform(_:)`` +/// - ``ConnectionServiceProtocol/Perform`` /// - ``perform(in:closure:)`` -public protocol DatabaseServiceProtocol: AnyObject, Sendable { - /// A closure executed with an active database connection. +public protocol DatabaseServiceProtocol: ConnectionServiceProtocol { + /// Executes a closure inside a transaction if the connection is in autocommit mode. /// - /// Used by the service to safely provide access to `Connection` - /// within the appropriate execution context. + /// If the connection operates in autocommit mode, this method starts a new transaction of the + /// specified type, executes the closure, and commits the changes on success. If the closure + /// throws an error, the transaction is rolled back. /// - /// - Parameter connection: The active database connection. - /// - Returns: The value returned by the closure. - /// - Throws: An error if the closure execution fails. - typealias Perform = (Connection) throws -> T - - /// The encryption key provider for the database service. + /// Implementations may attempt to re-establish the connection and reapply the encryption key if + /// an error indicates a lost or invalid database state (for example, `SQLiteError` with code + /// `SQLITE_NOTADB`). In such cases, the service can retry the transaction block once after a + /// successful reconnection. If reconnection fails or is disallowed by the key provider, the + /// original error is propagated. /// - /// 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 } - - /// Executes the given closure with an active connection. - /// - /// The closure receives the connection and may perform any - /// database operations within the current context. - /// - /// - 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) throws -> T - - /// Executes the given closure within a transaction. - /// - /// 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. + /// If a transaction is already active, the closure is executed directly without starting a new + /// transaction. /// /// - Parameters: - /// - transaction: The type of transaction to begin. - /// - closure: The closure that accepts a connection. + /// - 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: An error if one occurs during closure execution. + /// - Throws: Errors from connection creation, key application, configuration, transaction + /// management, or from the closure itself. + /// + /// - Important: The closure may be executed more than once if a reconnection occurs. Ensure it + /// performs only database operations and does not produce external side effects (such as + /// sending network requests or posting notifications). func perform(in transaction: TransactionType, closure: Perform) throws -> T } diff --git a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift index f1cde72..93cbfba 100644 --- a/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift +++ b/Sources/DataRaft/Protocols/MigrationServiceProtocol.swift @@ -1,48 +1,58 @@ import Foundation -/// Protocol for managing and executing database schema migrations. +/// A type that manages and executes database schema migrations. /// -/// Conforming types are responsible for registering migrations, applying -/// encryption keys (if required), and executing pending migrations in -/// ascending version order. +/// ## Overview /// -/// Migrations ensure that the database schema evolves consistently across -/// application versions without requiring manual intervention. +/// Conforming types are responsible for registering migration steps, 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 manual +/// intervention. +/// +/// ## Topics +/// +/// ### Associated Types +/// - ``Version`` +/// +/// ### Properties +/// - ``keyProvider`` +/// +/// ### Instance Methods +/// - ``add(_:)`` +/// - ``migrate()`` +/// - ``migrate()-18x5r`` public protocol MigrationServiceProtocol: AnyObject, Sendable { - /// Type representing the schema version used for migrations. + /// The type representing a schema version used for migrations. associatedtype Version: VersionRepresentable - /// Encryption key provider for the database service. - var keyProvider: DatabaseServiceKeyProvider? { get set } + /// The provider responsible for supplying encryption keys to the service. + var keyProvider: ConnectionServiceKeyProvider? { get set } /// 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. + /// - Throws: ``MigrationError/duplicateMigration(_:)`` if a migration with the same version + /// or script URL is already registered. func add(_ migration: Migration) throws(MigrationError) /// Executes 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. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute + /// or update the stored version. func migrate() throws(MigrationError) } -@available(iOS 13.0, *) -@available(macOS 10.15, *) +@available(iOS 13.0, macOS 10.15, *) public extension MigrationServiceProtocol { - /// Asynchronously executes all pending migrations in ascending order. + /// Asynchronously executes all pending migrations in ascending version order. /// - /// Performs the same logic as ``migrate()``, but runs asynchronously - /// on a background task with `.utility` priority. + /// 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. - /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a script execution - /// or version update fails. + /// - Throws: ``MigrationError/emptyMigrationScript(_:)`` if a migration script is empty. + /// - Throws: ``MigrationError/migrationFailed(_:_:)`` if a migration step fails to execute + /// or update the stored version. func migrate() async throws { try await Task(priority: .utility) { try self.migrate() diff --git a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift b/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift deleted file mode 100644 index 8422ccf..0000000 --- a/Sources/DataRaft/Protocols/RowDatabaseServiceProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import DataLiteCoder - -/// A protocol for database services that support row encoding and decoding. -/// -/// Conforming types provide `RowEncoder` and `RowDecoder` instances for serializing -/// and deserializing model types to and from SQLite row representations. -/// -/// This enables strongly typed, reusable, and safe access to database records -/// using Swift's `Codable` system. -public protocol RowDatabaseServiceProtocol: DatabaseServiceProtocol { - /// The encoder used to serialize values into database rows. - var encoder: RowEncoder { get } - - /// The decoder used to deserialize database rows into typed models. - var decoder: RowDecoder { get } -} diff --git a/Sources/DataRaft/Protocols/VersionRepresentable.swift b/Sources/DataRaft/Protocols/VersionRepresentable.swift deleted file mode 100644 index 8a276c8..0000000 --- a/Sources/DataRaft/Protocols/VersionRepresentable.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// A constraint that defines the requirements for a type used as a database schema version. -/// -/// This type alias specifies the minimal set of capabilities a version type must have -/// to participate in schema migrations. Conforming types must be: -/// -/// - `Equatable`: to check whether two versions are equal -/// - `Comparable`: to compare versions and determine ordering -/// - `Hashable`: to use versions as dictionary keys or in sets -/// - `Sendable`: to ensure safe use in concurrent contexts -/// -/// Use this alias as a base constraint when defining custom version types -/// for use with ``VersionStorage``. -/// -/// ```swift -/// struct SemanticVersion: VersionRepresentable { -/// let major: Int -/// let minor: Int -/// let patch: Int -/// -/// static func < (lhs: Self, rhs: Self) -> Bool { -/// if lhs.major != rhs.major { -/// return lhs.major < rhs.major -/// } -/// if lhs.minor != rhs.minor { -/// return lhs.minor < rhs.minor -/// } -/// return lhs.patch < rhs.patch -/// } -/// } -/// ``` -public typealias VersionRepresentable = Equatable & Comparable & Hashable & Sendable diff --git a/Sources/DataRaft/Protocols/VersionStorage.swift b/Sources/DataRaft/Protocols/VersionStorage.swift index 1bcfdd5..8761e62 100644 --- a/Sources/DataRaft/Protocols/VersionStorage.swift +++ b/Sources/DataRaft/Protocols/VersionStorage.swift @@ -1,36 +1,33 @@ import Foundation import DataLiteCore -/// A protocol that defines how the database version is stored and retrieved. +/// A type that defines how a database schema version is stored and retrieved. /// -/// This protocol decouples the concept of version representation from -/// the way the version is stored. It enables flexible implementations -/// that can store version values in different forms and places. +/// ## Overview /// -/// The associated `Version` type determines how the version is represented -/// (e.g. as an integer, a semantic string, or a structured object), while the -/// conforming type defines how that version is persisted. +/// This protocol separates the concept of version representation from its persistence mechanism, +/// allowing flexible implementations that store version values in different formats or locations. /// -/// Use this protocol to implement custom strategies for version tracking: -/// - Store an integer version in SQLite's `user_version` field. +/// The associated ``Version`` type specifies how the version is represented (for example, as an +/// integer, a semantic string, or a structured object), while the conforming type defines how that +/// version is persisted. +/// +/// ## Usage +/// +/// Implement this type to define a custom strategy for schema version tracking: +/// - Store an integer version in SQLite’s `user_version` field. /// - Store a string in a dedicated metadata table. /// - Store structured data in a JSON column. /// -/// To define your own versioning mechanism, implement `VersionStorage` -/// and choose a `Version` type that conforms to ``VersionRepresentable``. -/// -/// You can implement this protocol to define a custom way of storing the version -/// of a database schema. For example, the version could be a string stored in a metadata table. -/// -/// Below is an example of a simple implementation that stores the version string -/// in a table named `schema_version`. +/// The example below shows an implementation that stores the version string in a `schema_version` +/// table: /// /// ```swift /// final class StringVersionStorage: VersionStorage { /// typealias Version = String /// -/// func prepare(_ connection: Connection) throws { -/// let script: SQLScript = """ +/// func prepare(_ connection: ConnectionProtocol) throws { +/// let script = """ /// CREATE TABLE IF NOT EXISTS schema_version ( /// version TEXT NOT NULL /// ); @@ -42,7 +39,7 @@ import DataLiteCore /// try connection.execute(sql: script) /// } /// -/// func getVersion(_ connection: Connection) throws -> Version { +/// func getVersion(_ connection: ConnectionProtocol) throws -> Version { /// let query = "SELECT version FROM schema_version LIMIT 1" /// let stmt = try connection.prepare(sql: query) /// guard try stmt.step(), let value: Version = stmt.columnValue(at: 0) else { @@ -51,7 +48,7 @@ import DataLiteCore /// return value /// } /// -/// func setVersion(_ connection: Connection, _ version: Version) throws { +/// func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { /// let query = "UPDATE schema_version SET version = ?" /// let stmt = try connection.prepare(sql: query) /// try stmt.bind(version, at: 0) @@ -60,17 +57,6 @@ import DataLiteCore /// } /// ``` /// -/// This implementation works as follows: -/// -/// - `prepare(_:)` creates the `schema_version` table if it does not exist, and ensures that it -/// contains exactly one row with an initial version value (`"0.0.0"`). -/// -/// - `getVersion(_:)` reads the current version string from the single row in the table. -/// If the row is missing, it throws an error. -/// -/// - `setVersion(_:_:)` updates the version string in that row. A `WHERE` clause is not necessary -/// because the table always contains exactly one row. -/// /// ## Topics /// /// ### Associated Types @@ -83,58 +69,54 @@ import DataLiteCore /// - ``getVersion(_:)`` /// - ``setVersion(_:_:)`` public protocol VersionStorage { - /// A type representing the database schema version. + /// The type representing the database schema version. associatedtype Version: VersionRepresentable + /// Creates a new instance of the version storage. + init() + /// Prepares the storage mechanism for tracking the schema version. /// - /// This method is called before any version operations. Use it to create required tables - /// or metadata structures needed for version management. + /// Called before any version operations. Use this method to create required tables or metadata + /// structures for version management. /// - /// - Important: This method is executed within an active migration transaction. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameter connection: The database connection used for schema preparation. /// - Throws: An error if preparation fails. - func prepare(_ connection: Connection) throws + func prepare(_ connection: ConnectionProtocol) throws /// Returns the current schema version stored in the database. /// - /// This method must return a valid version previously stored by the migration system. + /// Must return a valid version previously stored by the migration system. /// - /// - Important: This method is executed within an active migration transaction. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameter connection: The database connection used to fetch the version. /// - Returns: The version currently stored in the database. /// - Throws: An error if reading fails or the version is missing. - func getVersion(_ connection: Connection) throws -> Version + func getVersion(_ connection: ConnectionProtocol) throws -> Version /// Stores the given version as the current schema version. /// - /// This method is called at the end of the migration process to persist - /// the final schema version after all migration steps have completed successfully. + /// Called at the end of the migration process to persist the final schema version after all + /// migration steps complete successfully. /// - /// - Important: This method is executed within an active migration transaction. - /// Do not issue `BEGIN` or `COMMIT` manually. If this method throws an error, - /// the entire migration process will be aborted and rolled back. + /// - Important: Executed within an active migration transaction. Do not issue `BEGIN` or + /// `COMMIT` manually. If this method throws an error, the migration process will be aborted + /// and rolled back. /// /// - Parameters: /// - connection: The database connection used to write the version. /// - version: The version to store. /// - Throws: An error if writing fails. - func setVersion(_ connection: Connection, _ version: Version) throws + func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws } public extension VersionStorage { - /// A default implementation that performs no preparation. - /// - /// Override this method if your storage implementation requires any setup, - /// such as creating a version table or inserting an initial value. - /// - /// If you override this method and it throws an error, the migration process - /// will be aborted and rolled back. - func prepare(_ connection: Connection) throws {} + func prepare(_ connection: ConnectionProtocol) throws {} } diff --git a/Sources/DataRaft/Structures/BitPackVersion.swift b/Sources/DataRaft/Structures/BitPackVersion.swift index f74add6..5b06d9a 100644 --- a/Sources/DataRaft/Structures/BitPackVersion.swift +++ b/Sources/DataRaft/Structures/BitPackVersion.swift @@ -109,8 +109,7 @@ public struct BitPackVersion: VersionRepresentable, RawRepresentable, CustomStri // MARK: - ExpressibleByStringLiteral -@available(iOS 16.0, *) -@available(macOS 13.0, *) +@available(iOS 16.0, macOS 13.0, *) extension BitPackVersion: ExpressibleByStringLiteral { /// An error related to parsing a version string. public enum ParseError: Swift.Error { diff --git a/Sources/DataRaft/Structures/Migration.swift b/Sources/DataRaft/Structures/Migration.swift index 12d40da..ef5b73a 100644 --- a/Sources/DataRaft/Structures/Migration.swift +++ b/Sources/DataRaft/Structures/Migration.swift @@ -1,41 +1,52 @@ import Foundation -import DataLiteCore -/// Represents a database migration step associated with a specific version. +/// A database migration step for a specific schema version. /// -/// Each `Migration` contains a reference to a migration script file (usually a `.sql` file) and the -/// version to which this script corresponds. The script is expected to be bundled with the application. +/// ## Overview /// -/// You can initialize a migration directly with a URL to the script, or load it from a resource -/// embedded in a bundle. +/// Each migration links a version identifier with a script file that modifies the database schema. +/// Scripts are typically bundled with the application and executed sequentially during version +/// upgrades. +/// +/// ## Topics +/// +/// ### Properties +/// - ``version`` +/// - ``scriptURL`` +/// - ``script`` +/// +/// ### Initializers +/// - ``init(version:scriptURL:)`` +/// - ``init(version:byResource:extension:in:)`` public struct Migration: Hashable, Sendable { // MARK: - Properties /// The version associated with this migration step. public let version: Version - /// The URL pointing to the migration script (e.g., an SQL file). + /// The file URL of the migration script (for example, an SQL file). public let scriptURL: URL - /// The SQL script associated with this migration. + /// The migration script as a string. /// - /// This computed property reads the contents of the file at `scriptURL` and returns it as a - /// `SQLScript` instance. Use this to access and execute the migration's SQL commands. + /// Reads the contents of the file at ``scriptURL`` and trims surrounding whitespace and + /// newlines. /// - /// - Throws: An error if the script file cannot be read or is invalid. - public var script: SQLScript { + /// - Throws: An error if the script file cannot be read. + public var script: String { get throws { - try SQLScript(contentsOf: scriptURL) + try String(contentsOf: scriptURL) + .trimmingCharacters(in: .whitespacesAndNewlines) } } // MARK: - Inits - /// Creates a migration with a specified version and script URL. + /// Creates a migration with the specified version and script URL. /// /// - Parameters: /// - version: The version this migration corresponds to. - /// - scriptURL: The file URL to the migration script. + /// - scriptURL: The URL of the script file to execute. public init(version: Version, scriptURL: URL) { self.version = version self.scriptURL = scriptURL @@ -43,33 +54,25 @@ public struct Migration: Hashable, Sendable { /// Creates a migration by locating a script resource in the specified bundle. /// - /// This initializer attempts to locate a script file in the provided bundle using the specified - /// resource `name` and optional `extension`. The `name` parameter may include or omit the file extension. - /// - /// - If `name` includes an extension (e.g., `"001_init.sql"`), pass `extension` as `nil` or an empty string. - /// - If `name` omits the extension (e.g., `"001_init"`), specify the extension separately - /// (e.g., `"sql"`), or leave it `nil` if the file has no extension. - /// - /// - Important: Passing a name that already includes the extension while also specifying a non-`nil` - /// `extension` may result in failure to locate the file. + /// Searches the given bundle for a script resource matching the provided name and optional file + /// extension. /// /// - Parameters: /// - version: The version this migration corresponds to. - /// - name: The resource name of the script file. May include or omit the file extension. - /// - extension: The file extension, if separated from the name. Defaults to `nil`. - /// - bundle: The bundle in which to search for the resource. Defaults to `.main`. + /// - name: The resource name of the script file. Can include or omit its extension. + /// - extension: The file extension, if separate from the name. Defaults to `nil`. + /// - bundle: The bundle in which to look for the resource. Defaults to `.main`. /// - /// - Returns: A `Migration` if the resource file is found; otherwise, `nil`. + /// - Returns: A `Migration` instance if the resource is found; otherwise, `nil`. public init?( version: Version, byResource name: String, extension: String? = nil, in bundle: Bundle = .main ) { - guard let url = bundle.url( - forResource: name, - withExtension: `extension` - ) else { return nil } + guard let url = bundle.url(forResource: name, withExtension: `extension`) else { + return nil + } self.init(version: version, scriptURL: url) } } diff --git a/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift b/Tests/DataRaftTests/Classes/DatabaseServiceTests.swift index 8af5157..6b605e2 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, @unchecked Sendable { +class DatabaseServiceTests: ConnectionServiceKeyProvider, @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, @@ -54,13 +54,16 @@ class DatabaseServiceTests: DatabaseServiceKeyProvider, @unchecked Sendable { try? FileManager.default.removeItem(at: fileURL) } - func databaseService(keyFor service: any DatabaseServiceProtocol) throws -> Connection.Key { + func connectionService(keyFor service: any ConnectionServiceProtocol) throws -> Connection.Key { currentKey } - func databaseService(shouldReconnect service: any DatabaseServiceProtocol) -> Bool { + func connectionService(shouldReconnect service: any ConnectionServiceProtocol) -> Bool { true } + + func connectionService(_ service: any ConnectionServiceProtocol, didReceive error: any Error) { + } } extension DatabaseServiceTests { @@ -138,8 +141,8 @@ extension DatabaseServiceTests { path: fileURL.path, options: [.readwrite] ) - try connection.apply(currentKey) - try connection.rekey(keyTwo) + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) currentKey = keyTwo try service.perform(in: .deferred) { connection in @@ -166,9 +169,9 @@ extension DatabaseServiceTests { path: fileURL.path, options: [.readwrite] ) - try connection.apply(currentKey) - try connection.rekey(keyTwo) - let error = Connection.Error( + try connection.apply(currentKey, name: nil) + try connection.rekey(keyTwo, name: nil) + let error = SQLiteError( code: SQLITE_NOTADB, message: "file is not a database" ) diff --git a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift index d4095a2..84e7849 100644 --- a/Tests/DataRaftTests/Classes/MigrationServiceTests.swift +++ b/Tests/DataRaftTests/Classes/MigrationServiceTests.swift @@ -3,7 +3,7 @@ import DataLiteCore @testable import DataRaft @Suite struct MigrationServiceTests { - private typealias MigrationService = DataRaft.MigrationService + private typealias MigrationService = DataRaft.MigrationService private typealias MigrationError = DataRaft.MigrationError private var connection: Connection! @@ -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(connection: connection) } @Test func addMigration() throws { @@ -68,7 +68,7 @@ import DataLiteCore @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)! + let migration4 = Migration(version: 4, byResource: "empty", extension: "sql", in: .module)! try migrationService.add(migration1) try migrationService.add(migration2) @@ -91,11 +91,11 @@ private extension MigrationServiceTests { struct VersionStorage: DataRaft.VersionStorage { typealias Version = Int32 - func getVersion(_ connection: Connection) throws -> Version { + func getVersion(_ connection: ConnectionProtocol) throws -> Version { connection.userVersion } - func setVersion(_ connection: Connection, _ version: Version) throws { + func setVersion(_ connection: ConnectionProtocol, _ version: Version) throws { connection.userVersion = version } } diff --git a/Tests/DataRaftTests/Resources/empty.sql b/Tests/DataRaftTests/Resources/empty.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Tests/DataRaftTests/Resources/empty.sql @@ -0,0 +1 @@ + diff --git a/Tests/DataRaftTests/Resources/migration_4.sql b/Tests/DataRaftTests/Resources/migration_4.sql deleted file mode 100644 index af447e5..0000000 --- a/Tests/DataRaftTests/Resources/migration_4.sql +++ /dev/null @@ -1 +0,0 @@ --- Empty Script diff --git a/Tests/DataRaftTests/Structures/MigrationTests.swift b/Tests/DataRaftTests/Structures/MigrationTests.swift index 17d113b..b4541ae 100644 --- a/Tests/DataRaftTests/Structures/MigrationTests.swift +++ b/Tests/DataRaftTests/Structures/MigrationTests.swift @@ -14,14 +14,13 @@ import Foundation } @Test func initFromBundle_success() throws { - let bundle = Bundle.module // или другой, если тестовая ресурсная цель другая let version = DummyVersion(rawValue: 2) let migration = Migration( version: version, byResource: "migration_1", extension: "sql", - in: bundle + in: .module ) #expect(migration != nil) From df17d21ec4c665be49eadc1ac91aaa21998a9ff3 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Sun, 9 Nov 2025 17:23:08 +0200 Subject: [PATCH 6/7] Add database change notification --- Package.swift | 4 +- .../DataRaft/Classes/DatabaseService.swift | 142 +++++++++--------- .../Classes/ModelDatabaseService.swift | 4 +- .../DataRaft/Classes/UserVersionStorage.swift | 11 +- .../Extensions/Notification+UserInfoKey.swift | 33 ---- .../Extensions/NotificationCenter.swift | 2 +- 6 files changed, 84 insertions(+), 112 deletions(-) delete mode 100644 Sources/DataRaft/Extensions/Notification+UserInfoKey.swift diff --git a/Package.swift b/Package.swift index 9995664..b9ebf06 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,8 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/angd-dev/data-lite-core.git", from: "1.0.0"), - .package(url: "https://github.com/angd-dev/data-lite-coder.git", from: "1.0.0"), + .package(url: "https://github.com/angd-dev/data-lite-core.git", .upToNextMinor(from: "1.1.0")), + .package(url: "https://github.com/angd-dev/data-lite-coder.git", .upToNextMinor(from: "1.0.0")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 492ba1b..6c74098 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -2,17 +2,17 @@ import Foundation import DataLiteCore import DataLiteC -/// A base database service handling transactions and event notifications. +/// A base database service that handles transactions and posts change notifications. /// /// ## Overview /// -/// `DatabaseService` provides a foundational layer for performing transactional database operations -/// within a thread-safe execution context. It automatically posts lifecycle notifications — such as -/// commit, rollback, and content changes — allowing observers to react to database updates in real -/// time. By default, it routes events through ``Foundation/NotificationCenter/database`` so that -/// clients can subscribe via a dedicated channel. This service is designed to be subclassed by -/// higher-level data managers that encapsulate domain logic while relying on consistent connection -/// and transaction handling. +/// `DatabaseService` provides a lightweight transactional layer for performing database operations +/// within a thread-safe execution context. It automatically detects modifications to the database +/// and posts a ``databaseDidChange`` notification database updates, allowing observers to react to +/// updates. +/// +/// This class is intended to be subclassed by higher-level data managers that encapsulate domain +/// logic while relying on consistent connection and transaction handling. /// /// ## Usage /// @@ -57,9 +57,6 @@ import DataLiteC /// ### Notifications /// /// - ``databaseDidChange`` -/// - ``databaseWillCommit`` -/// - ``databaseDidRollback`` -/// - ``databaseDidPerform`` open class DatabaseService: ConnectionService, DatabaseServiceProtocol, @@ -70,47 +67,26 @@ open class DatabaseService: private let center: NotificationCenter - /// Notification posted after the database content changes. - /// - /// Observers listen to this event to refresh cached data or update dependent components once - /// modifications are committed. The notification’s `userInfo` may include - /// ``Foundation/Notification/UserInfoKey/action`` describing the SQLite action. + /// Notification posted after the database content changes with this service. public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange") - /// Notification posted immediately before a transaction commits. - /// - /// Observers can perform validation or prepare for an upcoming state change while the - /// transaction is still in progress. - public static let databaseWillCommit = Notification.Name("DatabaseService.databaseWillCommit") - - /// Notification posted after a transaction rolls back. - /// - /// Observers use this event to revert in-memory state or reset caches that rely on pending - /// changes. - public static let databaseDidRollback = Notification.Name("DatabaseService.databaseDidRollback") - - /// Notification posted after any database operation completes, regardless of outcome. - /// - /// The service emits this event after finishing a `perform(_:)` block so observers can - /// synchronize state even when the operation is read-only or aborted. - /// - /// - Important: Confirm that the associated transaction was not rolled back before relying on - /// side effects. - public static let databaseDidPerform = Notification.Name("DatabaseService.databaseDidPerform") - // MARK: - Inits - /// Creates a database service that posts lifecycle events to the provided notification center. + /// Creates a database service with a specified notification center. /// - /// The underlying connection handling matches ``ConnectionService``; the connection is created - /// lazily and all work executes on the managed serial queue. + /// Configures an internal serial queue for thread-safe access to the database. The connection + /// itself is not created during initialization — it is established lazily on first use (for + /// example, inside ``perform(_:)``). + /// + /// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the + /// target of the internal queue. /// /// - Parameters: /// - provider: A closure that returns a new database connection. /// - config: An optional configuration closure called after the connection is established and /// the encryption key is applied. - /// - queue: An optional target queue for the internal serial queue. - /// - center: A notification center for posting database events. + /// - queue: An optional target queue for the internal one. + /// - center: The notification center used to post database change notifications. public init( provider: @escaping ConnectionProvider, config: ConnectionConfig? = nil, @@ -121,39 +97,54 @@ open class DatabaseService: super.init(provider: provider, config: config, queue: queue) } - /// Creates a database service that posts lifecycle events to the shared database notification - /// center. + /// Creates a database service using the default database notification center. /// - /// The connection is established lazily on first access and all work executes on the internal - /// queue defined in ``ConnectionService``. + /// Configures an internal serial queue for thread-safe access to the database. The connection + /// itself is not created during initialization — it is established lazily on first use (for + /// example, inside ``perform(_:)``). + /// + /// The service posts change notifications through ``Foundation/NotificationCenter/databaseCenter``, + /// which provides a shared channel for observing database events across the application. + /// + /// The internal queue is created with QoS `.utility`. If `queue` is provided, it becomes the + /// target of the internal queue. /// /// - Parameters: /// - provider: A closure that returns a new database connection. /// - config: An optional configuration closure called after the connection is established and /// the encryption key is applied. - /// - queue: An optional target queue for the internal serial queue. + /// - queue: An optional target queue for the internal one. public required init( provider: @escaping ConnectionProvider, config: ConnectionConfig? = nil, queue: DispatchQueue? = nil ) { - self.center = .database + self.center = .databaseCenter super.init(provider: provider, config: config, queue: queue) } // MARK: - Performing Operations - /// Executes a closure with a managed database connection and posts a completion notification. + /// Executes a closure within the context of a managed database connection. /// - /// The override mirrors ``ConnectionService/perform(_:)`` for queue-confined execution while - /// ensuring ``DatabaseService/databaseDidPerform`` is delivered after the closure completes. + /// Runs the operation on the service’s internal queue and ensures that the connection is valid + /// before use. If the connection is unavailable or fails during execution, this method throws + /// an error. /// - /// - Parameter closure: The operation to execute using the open connection. - /// - Returns: The value returned by the closure. - /// - Throws: Errors thrown by the closure or underlying connection. + /// After the closure completes, if the database content has changed, the service posts a + /// ``databaseDidChange`` notification through its configured notification center. + /// + /// - Parameter closure: The operation to perform using the connection. + /// - Returns: The result produced by the closure. + /// - Throws: An error thrown by the closure or the connection. public override func perform(_ closure: Perform) throws -> T { try super.perform { connection in - defer { center.post(name: Self.databaseDidPerform, object: self) } + let changes = connection.totalChanges + defer { + if changes != connection.totalChanges { + center.post(name: Self.databaseDidChange, object: self) + } + } return try closure(connection) } } @@ -221,28 +212,43 @@ open class DatabaseService: // MARK: - ConnectionDelegate - /// Posts ``DatabaseService/databaseDidChange`` when the database content updates. + /// Handles database updates reported by the active connection. + /// + /// Called after an SQL statement modifies the database content. Subclasses can override this + /// method to observe specific actions (for example, inserts, updates, or deletes). + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. /// /// - Parameters: - /// - connection: The connection that performed the change. - /// - action: The SQLite action describing the modification. - public func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) { - let userInfo = [Notification.UserInfoKey.action: action] - center.post(name: Self.databaseDidChange, object: self, userInfo: userInfo) + /// - connection: The connection that performed the update. + /// - action: The SQLite action describing the change. + open func connection(_ connection: any ConnectionProtocol, didUpdate action: SQLiteAction) { } - /// Posts ``DatabaseService/databaseWillCommit`` before a transaction commits. + /// Called immediately before the connection commits a transaction. + /// + /// Subclasses can override this method to perform validation or consistency checks prior to + /// committing. Throwing an error cancels the commit and triggers a rollback. + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. /// /// - Parameter connection: The connection preparing to commit. - public func connectionWillCommit(_ connection: any ConnectionProtocol) throws { - center.post(name: Self.databaseWillCommit, object: self) + /// - Throws: An error to cancel the commit and roll back the transaction. + open func connectionWillCommit(_ connection: any ConnectionProtocol) throws { } - /// Posts ``DatabaseService/databaseDidRollback`` after a transaction rollback. + /// Called after the connection rolls back a transaction. /// - /// - Parameter connection: The connection that rolled back. - public func connectionDidRollback(_ connection: any ConnectionProtocol) { - center.post(name: Self.databaseDidRollback, object: self) + /// Subclasses can override this method to handle cleanup or recovery logic following a + /// rollback. + /// + /// - Important: This method must not execute SQL statements or otherwise alter the connection + /// state. + /// + /// - Parameter connection: The connection that rolled back the transaction. + open func connectionDidRollback(_ connection: any ConnectionProtocol) { } // MARK: - Internal Methods diff --git a/Sources/DataRaft/Classes/ModelDatabaseService.swift b/Sources/DataRaft/Classes/ModelDatabaseService.swift index cb1b441..7f0599e 100644 --- a/Sources/DataRaft/Classes/ModelDatabaseService.swift +++ b/Sources/DataRaft/Classes/ModelDatabaseService.swift @@ -77,14 +77,14 @@ open class ModelDatabaseService: DatabaseService, @unchecked Sendable { /// - provider: A closure that returns a new database connection. /// - config: Optional configuration for the connection. /// - queue: The dispatch queue used for serializing database operations. - /// - center: The notification center used for database events. Defaults to `.database`. + /// - center: The notification center used for database events. Defaults to `.databaseCenter`. /// - encoder: The encoder for converting models into SQLite rows. /// - decoder: The decoder for converting rows back into model instances. public init( provider: @escaping ConnectionProvider, config: ConnectionConfig? = nil, queue: DispatchQueue? = nil, - center: NotificationCenter = .database, + center: NotificationCenter = .databaseCenter, encoder: RowEncoder, decoder: RowDecoder ) { diff --git a/Sources/DataRaft/Classes/UserVersionStorage.swift b/Sources/DataRaft/Classes/UserVersionStorage.swift index 5b051f9..58eb7c8 100644 --- a/Sources/DataRaft/Classes/UserVersionStorage.swift +++ b/Sources/DataRaft/Classes/UserVersionStorage.swift @@ -9,8 +9,8 @@ import DataLiteCore /// stores version data using the SQLite `PRAGMA user_version` mechanism. This approach is simple, /// efficient, and requires no additional tables. /// -/// The generic ``Version`` type must conform to both ``VersionRepresentable`` and -/// `RawRepresentable`, with `RawValue == UInt32`. This enables conversion between stored integer +/// The generic `Version` type must conform to both ``VersionRepresentable`` and `RawRepresentable`, +/// with `RawValue == UInt32`. This enables conversion between stored integer /// values and the application’s semantic version type. /// /// ## Topics @@ -26,10 +26,9 @@ import DataLiteCore public final class UserVersionStorage< Version: VersionRepresentable & RawRepresentable >: Sendable, VersionStorage where Version.RawValue == UInt32 { - /// Errors related to reading or decoding the stored version. public enum Error: Swift.Error { - /// The stored `user_version` value could not be decoded into a valid ``Version``. + /// The stored `user_version` value could not be decoded into a valid `Version`. /// /// - Parameter value: The invalid raw `UInt32` value. case invalidStoredVersion(UInt32) @@ -44,7 +43,7 @@ public final class UserVersionStorage< /// Returns the current schema version stored in the `user_version` field. /// - /// Reads the `PRAGMA user_version` value and attempts to decode it into a valid ``Version``. + /// Reads the `PRAGMA user_version` value and attempts to decode it into a valid `Version`. /// If decoding fails, this method throws an error. /// /// - Parameter connection: The active database connection. @@ -62,7 +61,7 @@ public final class UserVersionStorage< /// Stores the specified schema version in the `user_version` field. /// /// Updates the SQLite `PRAGMA user_version` value with the raw `UInt32` representation of the - /// provided ``Version``. + /// provided `Version`. /// /// - Parameters: /// - connection: The active database connection. diff --git a/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift b/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift deleted file mode 100644 index f682447..0000000 --- a/Sources/DataRaft/Extensions/Notification+UserInfoKey.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -extension Notification { - /// A strongly typed key used to access values in a notification’s user info dictionary. - /// - /// ## Overview - /// - /// `UserInfoKey` provides type safety when working with `Notification.userInfo`, replacing raw - /// string literals with well-defined constants. This helps prevent typos and improves - /// discoverability in database- or system-related notifications. - /// - /// ## Topics - /// - /// ### Keys - /// - /// - ``action`` - public struct UserInfoKey: RawRepresentable, Hashable, Sendable { - /// The raw string value of the key. - public let rawValue: String - - /// The key used to store the action associated with a notification. - public static let action = Self(rawValue: "action") - - /// Creates a user info key from the provided raw string value. - /// - /// Returns `nil` if the raw value is invalid. - /// - /// - Parameter rawValue: The raw string value of the key. - public init?(rawValue: String) { - self.rawValue = rawValue - } - } -} diff --git a/Sources/DataRaft/Extensions/NotificationCenter.swift b/Sources/DataRaft/Extensions/NotificationCenter.swift index 6f5a0a2..e059186 100644 --- a/Sources/DataRaft/Extensions/NotificationCenter.swift +++ b/Sources/DataRaft/Extensions/NotificationCenter.swift @@ -5,5 +5,5 @@ public extension NotificationCenter { /// /// Use this instance to post and observe notifications related to database lifecycle and /// operations instead of using the shared `NotificationCenter.default`. - static let database = NotificationCenter() + static let databaseCenter = NotificationCenter() } From 2220ab8ab45bcd1fd78eb9f970d94032f6b7a996 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Thu, 13 Nov 2025 17:52:29 +0200 Subject: [PATCH 7/7] Make database notification center optional --- Sources/DataRaft/Classes/DatabaseService.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/DataRaft/Classes/DatabaseService.swift b/Sources/DataRaft/Classes/DatabaseService.swift index 6c74098..79686fa 100644 --- a/Sources/DataRaft/Classes/DatabaseService.swift +++ b/Sources/DataRaft/Classes/DatabaseService.swift @@ -65,7 +65,7 @@ open class DatabaseService: { // MARK: - Properties - private let center: NotificationCenter + private let center: NotificationCenter? /// Notification posted after the database content changes with this service. public static let databaseDidChange = Notification.Name("DatabaseService.databaseDidChange") @@ -86,12 +86,12 @@ open class DatabaseService: /// - config: An optional configuration closure called after the connection is established and /// the encryption key is applied. /// - queue: An optional target queue for the internal one. - /// - center: The notification center used to post database change notifications. + /// - center: An optional notification center used to post database change notifications. public init( provider: @escaping ConnectionProvider, config: ConnectionConfig? = nil, queue: DispatchQueue? = nil, - center: NotificationCenter + center: NotificationCenter? ) { self.center = center super.init(provider: provider, config: config, queue: queue) @@ -142,7 +142,7 @@ open class DatabaseService: let changes = connection.totalChanges defer { if changes != connection.totalChanges { - center.post(name: Self.databaseDidChange, object: self) + center?.post(name: Self.databaseDidChange, object: self) } } return try closure(connection)