Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
.package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.0"),
.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"),
.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"),
// [docc-plugin-placeholder]
],
targets: [
.target(
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

SQLite driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package.

![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138)
[
![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)
](
https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.2
)

## Features

Expand Down Expand Up @@ -33,7 +37,7 @@ SQLite driver implementation for the abstract [Feather Database](https://github.
Add the dependency to your `Package.swift`:

```swift
.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.1"),
.package(url: "https://github.com/feather-framework/feather-sqlite-database", exact: "1.0.0-beta.2"),
```

Then add `FeatherSQLiteDatabase` to your target dependencies:
Expand All @@ -45,7 +49,11 @@ Then add `FeatherSQLiteDatabase` to your target dependencies:

## Usage

![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)
[
![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138)
](
https://feather-framework.github.io/feather-sqlite-database/documentation/feathersqlitedatabase/
)

API documentation is available at the following link.

Expand All @@ -61,15 +69,15 @@ import FeatherSQLiteDatabase
var logger = Logger(label: "example")
logger.logLevel = .info

let connection = try await SQLiteConnection.open(
let configuration = SQLiteClient.Configuration(
storage: .file(path: "/Users/me/db.sqlite"),
logger: logger
)

let database = SQLiteDatabaseClient(
connection: connection,
logger: logger
)
let client = SQLiteClient(configuration: configuration)
try await client.run()

let database = SQLiteDatabaseClient(client: client)

let result = try await database.execute(
query: #"""
Expand All @@ -85,7 +93,7 @@ for try await item in result {
print(version)
}

try await connection.close()
await client.shutdown()
```

> [!WARNING]
Expand All @@ -104,7 +112,7 @@ The following database driver implementations are available for use:
- Build: `swift build`
- Test:
- local: `swift test`
- using Docker: `swift docker-test`
- using Docker: `make docker-test`
- Format: `make format`
- Check: `make check`

Expand Down
245 changes: 245 additions & 0 deletions Sources/FeatherSQLiteDatabase/SQLiteClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
//
// SQLiteClient.swift
// feather-sqlite-database
//
// Created by Tibor Bödecs on 2026. 01. 26..
//

import FeatherDatabase
import Logging
import SQLiteNIO

/// A SQLite client backed by a connection pool.
///
/// Use this client to execute queries and transactions concurrently.
public final class SQLiteClient: Sendable {

/// Configuration values for a pooled SQLite client.
public struct Configuration: Sendable {

/// SQLite journal mode options for new connections.
public enum JournalMode: String, Sendable {
/// Roll back changes by copying the original content.
case delete = "DELETE"
/// Roll back changes by truncating the rollback journal.
case truncate = "TRUNCATE"
/// Roll back changes by zeroing the journal header.
case persist = "PERSIST"
/// Keep the journal in memory.
case memory = "MEMORY"
/// Use write-ahead logging to improve concurrency.
case wal = "WAL"
/// Disable the rollback journal.
case off = "OFF"
}

/// SQLite foreign key enforcement options for new connections.
public enum ForeignKeysMode: String, Sendable {
/// Disable foreign key enforcement.
case off = "OFF"
/// Enable foreign key enforcement.
case on = "ON"
}

/// The SQLite storage to open connections against.
public let storage: SQLiteConnection.Storage
/// Minimum number of pooled connections to keep open.
public let minimumConnections: Int
/// Maximum number of pooled connections to allow.
public let maximumConnections: Int
/// Logger used for pool operations.
public let logger: Logger
/// Journal mode applied to each pooled connection.
public let journalMode: JournalMode
/// Busy timeout, in milliseconds, applied to each pooled connection.
public let busyTimeoutMilliseconds: Int
/// Foreign key enforcement mode applied to each pooled connection.
public let foreignKeysMode: ForeignKeysMode

/// Create a SQLite client configuration.
/// - Parameters:
/// - storage: The SQLite storage to use.
/// - logger: The logger for database operations.
/// - minimumConnections: The minimum number of pooled connections.
/// - maximumConnections: The maximum number of pooled connections.
/// - journalMode: The journal mode to apply to connections.
/// - foreignKeysMode: The foreign key enforcement mode to apply.
/// - busyTimeoutMilliseconds: The busy timeout to apply, in milliseconds.
public init(
storage: SQLiteConnection.Storage,
logger: Logger,
minimumConnections: Int = 1,
maximumConnections: Int = 8,
journalMode: JournalMode = .wal,
foreignKeysMode: ForeignKeysMode = .on,
busyTimeoutMilliseconds: Int = 1000
) {
precondition(minimumConnections >= 0)
precondition(maximumConnections >= 1)
precondition(minimumConnections <= maximumConnections)
precondition(busyTimeoutMilliseconds >= 0)
self.storage = storage
self.minimumConnections = minimumConnections
self.maximumConnections = maximumConnections
self.logger = logger
self.journalMode = journalMode
self.foreignKeysMode = foreignKeysMode
self.busyTimeoutMilliseconds = busyTimeoutMilliseconds
}
}

private let pool: SQLiteConnectionPool

/// Create a SQLite client with a connection pool.
/// - Parameter configuration: The client configuration.
public init(configuration: Configuration) {
self.pool = SQLiteConnectionPool(
configuration: configuration
)
}

// MARK: - pool service

/// Pre-open the minimum number of connections.
public func run() async throws {
try await pool.warmup()
}

/// Close all pooled connections and refuse new leases.
public func shutdown() async {
await pool.shutdown()
}

// MARK: - database api

/// Execute a query using a managed connection.
///
/// This default implementation executes the query inside `connection(_:)`.
/// Busy errors are retried with an exponential backoff (up to 8 attempts).
/// - Parameters:
/// - isolation: The actor isolation to use for the duration of the call.
/// - query: The query to execute.
/// - Throws: A `DatabaseError` if execution fails.
/// - Returns: The query result.
@discardableResult
public func execute(
isolation: isolated (any Actor)? = #isolation,
query: SQLiteConnection.Query,
) async throws(DatabaseError) -> SQLiteConnection.Result {
try await connection(isolation: isolation) { connection in
try await connection.execute(query: query)
}
}

/// Execute work using a leased connection.
///
/// The connection is returned to the pool when the closure completes.
/// - Parameters:
/// - isolation: The actor isolation to use for the closure.
/// - closure: A closure that receives a SQLite connection.
/// - Throws: A `DatabaseError` if leasing or execution fails.
/// - Returns: The result produced by the closure.
@discardableResult
public func connection<T>(
isolation: isolated (any Actor)? = #isolation,
_ closure: (SQLiteConnection) async throws -> sending T
) async throws(DatabaseError) -> sending T {
let connection = try await leaseConnection()
do {
let result = try await closure(connection)
await pool.releaseConnection(connection)
return result
}
catch let error as DatabaseError {
await pool.releaseConnection(connection)
throw error
}
catch {
await pool.releaseConnection(connection)
throw .connection(error)
}
}

/// Execute work inside a SQLite transaction.
///
/// The transaction is committed on success and rolled back on failure.
/// Busy errors are retried with an exponential backoff (up to 8 attempts).
/// - Parameters:
/// - isolation: The actor isolation to use for the closure.
/// - closure: A closure that receives a SQLite connection.
/// - Throws: A `DatabaseError` if transaction handling fails.
/// - Returns: The result produced by the closure.
@discardableResult
public func transaction<T>(
isolation: isolated (any Actor)? = #isolation,
_ closure: (SQLiteConnection) async throws -> sending T
) async throws(DatabaseError) -> sending T {
let connection = try await leaseConnection()
do {
try await connection.execute(query: "BEGIN;")
}
catch {
await pool.releaseConnection(connection)
throw DatabaseError.transaction(
SQLiteTransactionError(beginError: error)
)
}

var closureHasFinished = false

do {
let result = try await closure(connection)
closureHasFinished = true

do {
try await connection.execute(query: "COMMIT;")
}
catch {
await pool.releaseConnection(connection)
throw DatabaseError.transaction(
SQLiteTransactionError(commitError: error)
)
}

await pool.releaseConnection(connection)
return result
}
catch {
var txError = SQLiteTransactionError()

if !closureHasFinished {
txError.closureError = error

do {
try await connection.execute(query: "ROLLBACK;")
}
catch {
txError.rollbackError = error
}
}
else {
txError.commitError = error
}

await pool.releaseConnection(connection)
throw DatabaseError.transaction(txError)
}
}

// MARK: - pool

func connectionCount() async -> Int {
await pool.connectionCount()
}

private func leaseConnection() async throws(DatabaseError)
-> SQLiteConnection
{
do {
return try await pool.leaseConnection()
}
catch {
throw .connection(error)
}
}
}
32 changes: 23 additions & 9 deletions Sources/FeatherSQLiteDatabase/SQLiteConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,29 @@ extension SQLiteConnection: @retroactive DatabaseConnection {
public func execute(
query: SQLiteQuery
) async throws(DatabaseError) -> SQLiteQueryResult {
do {
let result = try await self.query(
query.sql,
query.bindings
)
return SQLiteQueryResult(elements: result)
}
catch {
throw .query(error)
let maxAttempts = 8
var attempt = 0
while true {
do {
let result = try await self.query(
query.sql,
query.bindings
)
return SQLiteQueryResult(elements: result)
}
catch {
attempt += 1
if attempt >= maxAttempts {
throw .query(error)
}
let delayMilliseconds = min(1000, 25 << (attempt - 1))
do {
try await Task.sleep(for: .milliseconds(delayMilliseconds))
}
catch {
throw .query(error)
}
}
}
}
}
Loading