From e2ef90fc3452a676b6ed0618fa774bd6243e4bcd Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 28 Jul 2026 20:50:49 -0600 Subject: [PATCH 1/9] test: cover the full 64-bit `INTEGER` range Motivation: `SQLiteInt64` exists so that SQLite's 64-bit `INTEGER` affinity survives on 32-bit platforms, where carrying it in `Int` would trap above `Int32.max`, and both construction sites convert with `.init(...)` so the type comes from the typealias. Nothing exercises that range: the suite never stores a value wider than 32 bits, so a regression back to a hardcoded `Int(...)` conversion would compile everywhere and only be caught by trapping on a 32-bit target at runtime. Modifications: Add tests covering the full 64-bit range through both paths that construct `SQLiteData.integer` from libsqlite3: the column-read path, and the `sqlite3_value` path that custom functions see. Result: The wide-integer behavior is pinned by the suite everywhere, including the 32-bit targets (`wasm32`, `armv7k`) where `SQLiteInt64` resolves to `Int64`. --- Tests/SQLiteNIOTests/SQLiteNIOTests.swift | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 8618464..38cf42b 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -63,6 +63,40 @@ struct SQLiteNIOTests { } } + /// `INTEGER` columns must round-trip the full 64-bit range; a construction site which converted + /// through `Int` rather than ``SQLiteInt64`` would trap above `Int32.max` on a 32-bit target. + @Test + func largeIntegerRoundTrip() async throws { + try await withOpenedConnection { conn in + let values: [SQLiteInt64] = [.max, .min, 0, 1, -1, 0x7fff_ffff, 0x8000_0000, -0x8000_0001] + + _ = try await conn.query("CREATE TABLE bigints (value INTEGER)") + for value in values { + _ = try await conn.query("INSERT INTO bigints (value) VALUES (?)", [.integer(value)]) + } + + let rows = try await conn.query("SELECT value FROM bigints ORDER BY rowid") + + #expect(rows.compactMap { $0.column("value")?.integer } == values) + } + } + + /// The same range must survive `sqlite3_value` conversion, which is a separate code path from + /// column reads (it is the one custom functions see). + @Test + func largeIntegerThroughCustomFunction() async throws { + try await withOpenedConnection { conn in + let echo = SQLiteCustomFunction("echo_int", argumentCount: 1, pure: true) { args in + args[0].integer + } + + _ = try await conn.install(customFunction: echo) + let rows = try await conn.query("SELECT echo_int(?) as value", [.integer(.max)]) + + #expect(rows.first?.column("value")?.integer == .max) + } + } + @Test func dateFormat() async throws { try await withOpenedConnection { conn in From eb1d0098491e27734a215617ba0ea41a3b2c9c0b Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 28 Jul 2026 21:01:49 -0600 Subject: [PATCH 2/9] fix: use the zeroing tm initializer in the date fallback Motivation: The pre-macOS-12 date parsing fallback constructs a `tm` with the memberwise initializer, which spells out every field of the struct. That field list is not portable: wasi-libc's `struct tm` carries an extra `__tm_nsec` member, so the call does not compile there. The file's import list already anticipates WASI (`#elseif os(WASI)` selects WASILibc), but with no wasm lane in this package's CI the initializer call was never actually compiled against it. Modifications: Construct the `tm` with the zeroing initializer, which compiles against every libc regardless of the exact field set. The memberwise call also set `tm_wday` and `tm_yday` to -1, but as review pointed out, `timegm()` ignores both fields entirely, so the all-zero struct is enough on its own. Result: Identical behavior on every platform that compiled before, and the file now also compiles against wasi-libc. The `#available` check guarding the fallback is always true off Darwin, so the code remains unreachable everywhere except old Darwin hosts. --- Sources/SQLiteNIO/SQLiteDataConvertible.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteNIO/SQLiteDataConvertible.swift b/Sources/SQLiteNIO/SQLiteDataConvertible.swift index b2cf49c..e036f93 100644 --- a/Sources/SQLiteNIO/SQLiteDataConvertible.swift +++ b/Sources/SQLiteNIO/SQLiteDataConvertible.swift @@ -164,10 +164,11 @@ extension Date: SQLiteDataConvertible { // this code actually does work. It's ugly, but it works. And deeply sadly, it too is // much, much faster than ISO8601DateFormatter... More importantly, it allows us to actually // stick to importing FoundationEssentials. - var stm = tm( - tm_sec: 0, tm_min: 0, tm_hour: 0, tm_mday: 0, tm_mon: 0, tm_year: 0, - tm_wday: -1, tm_yday: -1, tm_isdst: 0, tm_gmtoff: 0, tm_zone: nil - ) + // N.B.: Spelled with the zeroing initializer, rather than memberwise, because libcs + // disagree on what `tm`'s fields are (wasi-libc adds a `__tm_nsec` member, for + // example), and the zeroing initializer compiles against all of them. `timegm()` + // ignores `tm_wday` and `tm_yday`, so all-zero is a valid starting point. + var stm = tm() guard v.count == 10 || v.count == 19, v.prefix(5).last == "-", v.prefix(8).last == "-", let y = Int32(v.prefix(4)), let n = Int32(v.prefix(7).suffix(2)), let d = Int32(v.prefix(10).suffix(2)) else { return nil } From b8f11386e3cb008803024bcdd5d98dacb086d6d0 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 28 Jul 2026 21:02:10 -0600 Subject: [PATCH 3/9] feat: select a SwiftNIO-free configuration via `canImport(NIOCore)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: SwiftNIO cannot be built for `wasm32-unknown-wasip1`: NIOPosix is built on POSIX sockets and threads, neither of which WASI preview 1 provides. SQLiteNIO itself needs very little of SwiftNIO: an `EventLoopFuture` API, `ByteBuffer` for blobs, and an `NIOThreadPool` to keep blocking libsqlite3 calls off the event loops. It can build without any of that, given somewhere to put the differences. The goal is one implementation with a few conditional declarations, not a second copy of the package that has to be kept in step by hand. Modifications: Gate on `#if canImport(NIOCore)` rather than on a platform check, so the sources follow whatever the manifest actually resolves for the target being built. When SwiftNIO is present every gate is true and nothing changes. Where it is absent: - `Exports.swift` defines `ByteBuffer` as `[UInt8]` and supplies, as internal members, the five `ByteBuffer` APIs this package uses, plus a single-threaded stand-in for `NIOLockedValueBox` so the `sqlite3_initialize()` guard in the open sequence reads identically in both configurations. - `SQLiteConnection` keeps one class. The `EventLoopFuture` members are gated; the `async` members are shared, with `withBlockingIO(_:)` standing in for `NIOThreadPool.runIfActive` where there is no pool to offload to, and `openHandle(storage:logger:)` holding the open sequence that every `open(…)` overload needs. - `SQLiteConnection.execute(_:_:_:)` now holds the prepare/bind/step loop, and the futures-based and `async` query entry points both call it, so the loop exists once instead of twice. - `SQLiteDatabase` keeps one protocol declaration, and its `async` requirements are exactly the upstream ones, shared by both configurations; only the `EventLoop`-shaped requirements and the futures-routed default implementations are conditional. Read blobs with `ByteBuffer(bytes:)` instead of allocating and then writing, and build `Data` from `readableBytesView` instead of `Data(buffer:byteTransferStrategy:)`, so those expressions compile against both blob representations. Both are equivalent to what they replace. The observable hook API is the one thing with no counterpart here: it is built on `NIOThreadPool` and `NIOLockedValueBox` throughout, so the file is gated whole. Beyond the no-op locked-value stand-in, no synchronization is introduced: `SQLiteConnectionHandle` is already `@unchecked Sendable` for the reasons documented on it, and row collection uses the same `nonisolated(unsafe)` accumulator the futures-based overload uses. Result: On every platform that links SwiftNIO the public API, the resolved symbol graph, and the behavior are unchanged; `diagnose-api-breaking-changes` reports nothing, and no symbol is added. Where SwiftNIO is absent, SQLiteNIO offers the same `async` API over the same libsqlite3 handle, with no SwiftNIO module in the build graph at all. --- Sources/SQLiteNIO/Exports.swift | 60 +++++++++++ .../SQLiteNIO/SQLiteConnection+Hooks.swift | 4 + Sources/SQLiteNIO/SQLiteConnection.swift | 102 ++++++++++++++---- Sources/SQLiteNIO/SQLiteData.swift | 2 + Sources/SQLiteNIO/SQLiteDataConvertible.swift | 10 +- Sources/SQLiteNIO/SQLiteDatabase.swift | 20 +++- Sources/SQLiteNIO/SQLiteStatement.swift | 13 ++- Tests/SQLiteNIOTests/SQLiteNIOTests.swift | 50 +++++++++ 8 files changed, 234 insertions(+), 27 deletions(-) diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index 0542622..876238b 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -1,5 +1,65 @@ +#if canImport(NIOCore) @_documentation(visibility: internal) @_exported import struct NIOCore.ByteBuffer @_documentation(visibility: internal) @_exported import class NIOPosix.NIOThreadPool @_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoop @_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoopGroup @_documentation(visibility: internal) @_exported import class NIOPosix.MultiThreadedEventLoopGroup +#else // !canImport(NIOCore) +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// `BLOB` values are carried by `[UInt8]` on platforms where SwiftNIO is not available. +/// +/// SwiftNIO does not support `wasm32-unknown-wasip1`, so `NIOCore.ByteBuffer` cannot be re-exported +/// above (see the product conditions in `Package.swift`). Declaring the substitution as a typealias +/// rather than forking every declaration that mentions `ByteBuffer` keeps ``SQLiteData`` and friends +/// as a single implementation across both configurations. +public typealias ByteBuffer = [UInt8] + +/// The subset of `NIOCore.ByteBuffer`'s API that this package uses, expressed over `[UInt8]`. +/// +/// These are deliberately not `public`: they exist only so the shared implementation compiles +/// unchanged, and making them public would graft NIO-flavored members onto every `[UInt8]` in any +/// module that imports `SQLiteNIO`. +extension ByteBuffer { + init(bytes: some Sequence) { + self.init(bytes) + } + + init(data: Data) { + self.init(data) + } + + var readableBytes: Int { + self.count + } + + var readableBytesView: Self { + self + } + + func withUnsafeReadableBytes(_ body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { + try self.withUnsafeBytes(body) + } +} + +/// A single-threaded stand-in for `NIOConcurrencyHelpers.NIOLockedValueBox`. +/// +/// The one supported SwiftNIO-free target is single-threaded, so no lock is needed and none is +/// taken: the box exists so the `sqlite3_initialize()` guard in ``SQLiteConnection`` reads the +/// same in both configurations. `@unchecked Sendable` is sound for the same reason. +final class NIOLockedValueBox: @unchecked Sendable { + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + try mutate(&self.value) + } +} +#endif // !canImport(NIOCore) diff --git a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift index e9f9c93..9d95d0a 100644 --- a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift +++ b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift @@ -1,3 +1,6 @@ +// The hook API is built on `NIOThreadPool` and `NIOLockedValueBox`; it has no NIO-free +// counterpart, so it is elided along with the rest of the SwiftNIO surface. +#if canImport(NIOCore) #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -869,3 +872,4 @@ extension SQLiteConnection { self.observerBuckets.withLockedValue { $0 = .init() } } } +#endif // canImport(NIOCore) diff --git a/Sources/SQLiteNIO/SQLiteConnection.swift b/Sources/SQLiteNIO/SQLiteConnection.swift index ced69ce..bf38ee5 100644 --- a/Sources/SQLiteNIO/SQLiteConnection.swift +++ b/Sources/SQLiteNIO/SQLiteConnection.swift @@ -1,6 +1,8 @@ +#if canImport(NIOCore) import NIOConcurrencyHelpers import NIOCore import NIOPosix +#endif import VaporCSQLite import Logging @@ -13,7 +15,8 @@ import Logging /// /// The use of `@unchecked Sendable` is safe for this type because: /// -/// - We ensure that access to the raw handle only ever takes place while running on an `NIOThreadPool`. +/// - We ensure that access to the raw handle only ever takes place while running on an `NIOThreadPool`, +/// or, on single-threaded targets without SwiftNIO, on the only thread there is. /// This does not prevent concurrent access to the handle from multiple threads, but does tend to limit /// the possibility of misuse (and of course prevents CPU-bound work from ending up on an event loop). /// - The embedded SQLite is built with `SQLITE_THREADSAFE=1` (serialized mode, permitting safe use of a @@ -139,6 +142,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { String(cString: sqlite_nio_sqlite3_libversion()) } + #if canImport(NIOCore) /// Open a new connection to an SQLite database. /// /// This is equivalent to invoking ``open(storage:threadPool:logger:on:)-64n3x`` using the @@ -188,6 +192,19 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { logger: Logger, eventLoop: any EventLoop ) throws -> SQLiteConnection { + SQLiteConnection( + handle: try self.openHandle(storage: storage, logger: logger), + threadPool: threadPool, + logger: logger, + on: eventLoop + ) + } + #endif // canImport(NIOCore) + + /// Open the libsqlite3 handle for the given storage, configure it, and return it. + /// + /// Every `open(…)` overload funnels through this method, so the actual open sequence exists once. + private static func openHandle(storage: Storage, logger: Logger) throws -> OpaquePointer? { let path: String switch storage { case .memory: path = ":memory:" @@ -222,11 +239,13 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { } logger.debug("Connected to sqlite database", metadata: ["path": .string(path)]) - return SQLiteConnection(handle: handle, threadPool: threadPool, logger: logger, on: eventLoop) + return handle } + #if canImport(NIOCore) // See `SQLiteDatabase.eventLoop`. public let eventLoop: any EventLoop + #endif // See `SQLiteDatabase.logger`. public let logger: Logger @@ -234,6 +253,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { /// The underlying `sqlite3` connection handle. let handle: SQLiteConnectionHandle + #if canImport(NIOCore) /// The thread pool used by this connection when calling libsqlite3 APIs. let threadPool: NIOThreadPool @@ -252,7 +272,14 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { self.logger = logger self.eventLoop = eventLoop } - + #else + /// Initialize a new ``SQLiteConnection``. Internal use only. + private init(handle: OpaquePointer?, logger: Logger) { + self.handle = .init(handle) + self.logger = logger + } + #endif // canImport(NIOCore) + /// Returns the most recent error message from the connection as a string. /// /// This is only valid until another operation is performed on the connection; watch out for races. @@ -265,6 +292,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { self.handle.raw == nil } + #if canImport(NIOCore) /// Returns the last value generated by auto-increment functionality (either the version implied by /// `INTEGER PRIMARY KEY` or that of the explicit `AUTO_INCREMENT` modifier) on this database. /// @@ -302,10 +330,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { } var futures: [EventLoopFuture] = [] do { - var statement = try SQLiteStatement(query: query, on: self) - let columns = try statement.columns() - try statement.bind(binds) - while let row = try statement.nextRow(for: columns) { + try self.execute(query, binds) { row in futures.append(promise.futureResult.eventLoop.submit { onRow(row) }) } } catch { @@ -350,6 +375,40 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { try customFunction.uninstall(in: self) } } + #endif // canImport(NIOCore) + + /// Prepare, bind, and step `query`, invoking `handleRow` once per result row. + /// + /// This is the only place statements are actually executed; both the `EventLoopFuture` and the + /// `async` entry points funnel through it. + /// + /// > Important: This blocks. Callers are responsible for ensuring it never runs on an `EventLoop`. + func execute( + _ query: String, + _ binds: [SQLiteData], + _ handleRow: (SQLiteRow) throws -> Void + ) throws { + var statement = try SQLiteStatement(query: query, on: self) + let columns = try statement.columns() + + try statement.bind(binds) + while let row = try statement.nextRow(for: columns) { + try handleRow(row) + } + } + + /// Run `body`, which performs blocking libsqlite3 work, somewhere it is safe to block. + /// + /// On SwiftNIO builds that is the connection's `NIOThreadPool`, exactly as for the futures-based + /// API. Where SwiftNIO is unavailable the only supported target is single-threaded, so there is no + /// pool to offload to and `body` runs inline on the calling task. + func withBlockingIO(_ body: @escaping @Sendable () throws -> T) async throws -> T { + #if canImport(NIOCore) + try await self.threadPool.runIfActive(body) + #else + try body() + #endif + } /// Deinitializer for ``SQLiteConnection``. deinit { @@ -372,14 +431,19 @@ extension SQLiteConnection { storage: Storage = .memory, logger: Logger = .init(label: "codes.vapor.sqlite") ) async throws -> SQLiteConnection { + #if canImport(NIOCore) try await Self.open( storage: storage, threadPool: NIOThreadPool.singleton, logger: logger, on: MultiThreadedEventLoopGroup.singleton.any() ) + #else + SQLiteConnection(handle: try Self.openHandle(storage: storage, logger: logger), logger: logger) + #endif } - + + #if canImport(NIOCore) /// Open a new connection to an SQLite database. /// /// - Parameters: @@ -398,7 +462,8 @@ extension SQLiteConnection { try self.openInternal(storage: storage, threadPool: threadPool, logger: logger, eventLoop: eventLoop) } } - + #endif // canImport(NIOCore) + /// Returns the last value generated by auto-increment functionality (either the version implied by /// `INTEGER PRIMARY KEY` or that of the explicit `AUTO_INCREMENT` modifier) on this database. /// @@ -406,7 +471,7 @@ extension SQLiteConnection { /// /// - Returns: The most recently inserted rowid value. public func lastAutoincrementID() async throws -> Int { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { numericCast(sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw)) } } @@ -424,13 +489,8 @@ extension SQLiteConnection { _ binds: [SQLiteData], _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) async throws { - try await self.threadPool.runIfActive { - var statement = try SQLiteStatement(query: query, on: self) - let columns = try statement.columns() - try statement.bind(binds) - while let row = try statement.nextRow(for: columns) { - onRow(row) - } + try await self.withBlockingIO { + try self.execute(query, binds, onRow) } } @@ -438,8 +498,10 @@ extension SQLiteConnection { /// /// No further operations may be performed on the connection after calling this method. public func close() async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { + #if canImport(NIOCore) self.clearAllHooks() + #endif sqlite_nio_sqlite3_close(self.handle.raw) self.handle.raw = nil } @@ -449,7 +511,7 @@ extension SQLiteConnection { /// /// - Parameter customFunction: The function to install. public func install(customFunction: SQLiteCustomFunction) async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.logger.trace("Adding custom function \(customFunction.name)") try customFunction.install(in: self) } @@ -459,7 +521,7 @@ extension SQLiteConnection { /// /// - Parameter customFunction: The function to remove. public func uninstall(customFunction: SQLiteCustomFunction) async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.logger.trace("Removing custom function \(customFunction.name)") try customFunction.uninstall(in: self) } diff --git a/Sources/SQLiteNIO/SQLiteData.swift b/Sources/SQLiteNIO/SQLiteData.swift index 0008332..860a57e 100644 --- a/Sources/SQLiteNIO/SQLiteData.swift +++ b/Sources/SQLiteNIO/SQLiteData.swift @@ -1,5 +1,7 @@ import VaporCSQLite +#if canImport(NIOCore) import NIOCore +#endif /// On 32-bit platforms, use an explicitly 64-bit integer type. On other platforms, use the platform-native /// integer width, as we don't expect to ever support platforms with less than 32 bits. Most users will not diff --git a/Sources/SQLiteNIO/SQLiteDataConvertible.swift b/Sources/SQLiteNIO/SQLiteDataConvertible.swift index e036f93..67deaa2 100644 --- a/Sources/SQLiteNIO/SQLiteDataConvertible.swift +++ b/Sources/SQLiteNIO/SQLiteDataConvertible.swift @@ -12,12 +12,18 @@ import WASILibc import CRT #endif +#if canImport(NIOCore) import NIOCore +#endif #if canImport(FoundationEssentials) +#if canImport(NIOCore) import NIOFoundationEssentialsCompat +#endif import FoundationEssentials #else +#if canImport(NIOCore) import NIOFoundationCompat +#endif import Foundation #endif @@ -111,7 +117,9 @@ extension Data: SQLiteDataConvertible { guard case .blob(let value) = sqliteData else { return nil } - self = .init(buffer: value, byteTransferStrategy: .copy) + // N.B.: Spelled this way, rather than as `Data(buffer:byteTransferStrategy:)`, so that the + // same expression compiles against the `[UInt8]` stand-in used where SwiftNIO is absent. + self = .init(value.readableBytesView) } public var sqliteData: SQLiteData? { diff --git a/Sources/SQLiteNIO/SQLiteDatabase.swift b/Sources/SQLiteNIO/SQLiteDatabase.swift index 2ccfb66..bc147c7 100644 --- a/Sources/SQLiteNIO/SQLiteDatabase.swift +++ b/Sources/SQLiteNIO/SQLiteDatabase.swift @@ -1,5 +1,7 @@ +#if canImport(NIOCore) import NIOCore import NIOPosix +#endif import VaporCSQLite import Logging @@ -12,9 +14,12 @@ public protocol SQLiteDatabase: Sendable { /// The logger used by the connection. var logger: Logger { get } + #if canImport(NIOCore) /// The event loop on which operations on the connection execute. var eventLoop: any EventLoop { get } + #endif + #if canImport(NIOCore) /// Execute a query on the connection, calling the provided closure for each result row (if any). /// /// This is the primary interface to connections vended via this protocol. @@ -40,7 +45,8 @@ public protocol SQLiteDatabase: Sendable { logger: Logger, _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) -> EventLoopFuture - + #endif // canImport(NIOCore) + /// Execute a query on the connection, calling the provided closure for each result row (if any). /// /// This is the primary Concurrency-based interface to connections vended via this protocol. A default @@ -57,6 +63,7 @@ public protocol SQLiteDatabase: Sendable { _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) async throws + #if canImport(NIOCore) /// Call the provided closure with a concrete ``SQLiteConnection`` instance. /// /// This method is required to provide a connection object which executes all queries directed to it in the @@ -70,6 +77,7 @@ public protocol SQLiteDatabase: Sendable { func withConnection( _ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture ) -> EventLoopFuture + #endif // canImport(NIOCore) /// Call the provided closure with a concrete ``SQLiteConnection`` instance, concurrency version. /// @@ -88,6 +96,7 @@ public protocol SQLiteDatabase: Sendable { /// Convenience helpers and Concurrency-aware variants. extension SQLiteDatabase { + #if canImport(NIOCore) /// Convenience method for calling ``query(_:_:logger:_:)`` with the connection's logger. /// /// Callers are strongly encouraged to always use this method or its async equivalent (``query(_:_:_:)``) instead @@ -119,6 +128,7 @@ extension SQLiteDatabase { return self.query(query, binds, logger: self.logger) { rows.append($0) }.map { rows } } + #endif // canImport(NIOCore) /// Wrapper for ``query(_:_:_:)`` which returns the result rows (if any) rather than calling a /// closure (async version). @@ -129,6 +139,7 @@ extension SQLiteDatabase { return rows } + #if canImport(NIOCore) /// Async version of ``withConnection(_:)-48y34``. public func withConnection( _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T @@ -139,6 +150,7 @@ extension SQLiteDatabase { } }.get() } + #endif // canImport(NIOCore) } #if swift(<5.10) @@ -181,6 +193,7 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { // See `SQLiteDatabase.logger`. let logger: Logger + #if canImport(NIOCore) // See `SQLiteDatabase.eventLoop`. var eventLoop: any EventLoop { self.database.eventLoop } @@ -188,11 +201,13 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture) -> EventLoopFuture { self.database.withConnection(closure) } + #endif // canImport(NIOCore) // See `SQLiteDatabase.withConnection(_:)`. func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) async throws -> T) async throws -> T { try await self.database.withConnection(closure) } + #if canImport(NIOCore) // See `SQLiteDatabase.query(_:_:_:)`. func query(_ query: String, _ binds: [SQLiteData], logger: Logger, _ onRow: @escaping @Sendable (SQLiteRow) -> Void) -> EventLoopFuture { self.database.query(query, binds, logger: logger, onRow) @@ -202,16 +217,19 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { func query(_ query: String, _ binds: [SQLiteData] = [], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) -> EventLoopFuture { self.database.query(query, binds, onRow) } + #endif // canImport(NIOCore) // See `SQLiteDatabase.query(_:_:_:)`. func query(_ query: String, _ binds: [SQLiteData], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) async throws { try await self.database.query(query, binds, onRow) } + #if canImport(NIOCore) // See `SQLiteDatabase.query(_:_:)`. func query(_ query: String, _ binds: [SQLiteData] = []) -> EventLoopFuture<[SQLiteRow]> { self.database.query(query, binds) } + #endif // See `SQLiteDatabase.query(_:_:)`. func query(_ query: String, _ binds: [SQLiteData] = []) async throws -> [SQLiteRow] { diff --git a/Sources/SQLiteNIO/SQLiteStatement.swift b/Sources/SQLiteNIO/SQLiteStatement.swift index 2a30ed7..1f0cb3b 100644 --- a/Sources/SQLiteNIO/SQLiteStatement.swift +++ b/Sources/SQLiteNIO/SQLiteStatement.swift @@ -1,4 +1,6 @@ +#if canImport(NIOCore) import NIOCore +#endif import VaporCSQLite struct SQLiteStatement { @@ -102,12 +104,13 @@ struct SQLiteStatement { return .text(.init(cString: val)) case SQLITE_BLOB: let length = Int(sqlite_nio_sqlite3_column_bytes(self.handle, offset)) - var buffer = ByteBufferAllocator().buffer(capacity: length) - - if let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) { - buffer.writeBytes(UnsafeRawBufferPointer(start: blobPointer, count: length)) + + guard let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) else { + return .blob(ByteBuffer()) } - return .blob(buffer) + // N.B.: This is `ByteBuffer(bytes:)` rather than an allocate-then-write pair so that the + // same expression compiles against the `[UInt8]` stand-in used where SwiftNIO is absent. + return .blob(ByteBuffer(bytes: UnsafeRawBufferPointer(start: blobPointer, count: length))) case SQLITE_NULL: return .null default: diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 38cf42b..63ad2ef 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -97,6 +97,56 @@ struct SQLiteNIOTests { } } + /// A `BLOB` must survive the bind/column round trip byte-for-byte, including the empty case. + /// + /// The blob read in ``SQLiteStatement`` is a single expression shared by the SwiftNIO and + /// NIO-free builds, so its behavior is worth pinning down directly. + @Test + func blobRoundTrip() async throws { + try await withOpenedConnection { conn in + let payloads: [[UInt8]] = [[], [0x00], [0xde, 0xad, 0xbe, 0xef], .init(0...255)] + + _ = try await conn.query("CREATE TABLE blobs (value BLOB)") + for payload in payloads { + _ = try await conn.query("INSERT INTO blobs (value) VALUES (?)", [.blob(ByteBuffer(bytes: payload))]) + } + + let rows = try await conn.query("SELECT value FROM blobs ORDER BY rowid") + + #expect(rows.compactMap { $0.column("value")?.blob.map { Array($0.readableBytesView) } } == payloads) + } + } + + /// `Data` round-trips through `BLOB` in both directions. + /// + /// Its ``SQLiteDataConvertible`` conformance is spelled so that one implementation compiles against + /// both `ByteBuffer` and the `[UInt8]` stand-in used where SwiftNIO is absent. + @Test + func dataRoundTrip() async throws { + try await withOpenedConnection { conn in + let payload = Data([0x00, 0x01, 0xfe, 0xff]) + + _ = try await conn.query("CREATE TABLE datas (value BLOB)") + _ = try await conn.query("INSERT INTO datas (value) VALUES (?)", [payload.sqliteData!]) + + let rows = try await conn.query("SELECT value FROM datas") + + #expect(rows.first?.column("value").flatMap(Data.init(sqliteData:)) == payload) + #expect(Data(sqliteData: .blob(ByteBuffer())) == Data()) + #expect(Data(sqliteData: .null) == nil) + } + } + + /// ``SQLiteData`` encodes blobs as raw bytes rather than using `ByteBuffer`'s Base64 `Codable` + /// conformance. The encoding goes through `readableBytesView`, one of the members the NIO-free + /// build supplies for `[UInt8]`. + @Test + func blobEncodesAsRawBytes() throws { + let encoded = try JSONEncoder().encode([SQLiteData.blob(ByteBuffer(bytes: [0x01, 0x02, 0x03]))]) + + #expect(String(decoding: encoded, as: UTF8.self) == "[[1,2,3]]") + } + @Test func dateFormat() async throws { try await withOpenedConnection { conn in From f264226a4de50459df30c6f41e6bdf07e8febd4b Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 29 Jul 2026 16:11:05 -0600 Subject: [PATCH 4/9] feat: enable the hook API where SwiftNIO is absent Motivation: The previous commit gated the observation hook API out of the SwiftNIO-free configuration wholesale, on the grounds that it is built on `NIOThreadPool` and `NIOLockedValueBox` throughout. Review pointed out that is the wrong reading: the hook API's public surface has no NIO dependency at all, not even a `ByteBuffer`, and it is already `async`. The thread pool is only involved because event loops are unsuitable for CPU-bound work, and where SwiftNIO is absent there is no event loop to protect; the code can simply run where it is called. Modifications: Narrow the whole-file `canImport(NIOCore)` gate in `SQLiteConnection+Hooks.swift` to the two NIO imports, and route the six hook registration methods through `withBlockingIO(_:)`, the same helper the rest of the shared `async` surface uses, in place of direct `NIOThreadPool.runIfActive` calls. Move the `observerBuckets` property out of the SwiftNIO-only section of `SQLiteConnection`. Where SwiftNIO is absent it is backed by the single-threaded `NIOLockedValueBox` stand-in `Exports.swift` already provides, so the hook implementation compiles unchanged, and `close()` no longer needs a gate around its `clearAllHooks()` call. The C dispatcher machinery needs no changes: SQLite invokes those callbacks synchronously on whatever thread runs the statement, so none of it ever depended on NIO. Result: Where SwiftNIO is present, nothing changes. Where it is absent, the full hook API (update, commit, rollback, and authorizer observers and validators) is now available with upstream semantics; on the single-threaded targets that configuration supports, callbacks fire inline during statement execution. --- Sources/SQLiteNIO/Exports.swift | 5 +++-- Sources/SQLiteNIO/SQLiteConnection+Hooks.swift | 18 ++++++++---------- Sources/SQLiteNIO/SQLiteConnection.swift | 10 ++++------ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index 876238b..e3a72d0 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -49,8 +49,9 @@ extension ByteBuffer { /// A single-threaded stand-in for `NIOConcurrencyHelpers.NIOLockedValueBox`. /// /// The one supported SwiftNIO-free target is single-threaded, so no lock is needed and none is -/// taken: the box exists so the `sqlite3_initialize()` guard in ``SQLiteConnection`` reads the -/// same in both configurations. `@unchecked Sendable` is sound for the same reason. +/// taken: the box exists so the `sqlite3_initialize()` guard and the hook observer storage in +/// ``SQLiteConnection`` read the same in both configurations. `@unchecked Sendable` is sound for +/// the same reason. final class NIOLockedValueBox: @unchecked Sendable { private var value: Value diff --git a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift index 9d95d0a..be96916 100644 --- a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift +++ b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift @@ -1,13 +1,12 @@ -// The hook API is built on `NIOThreadPool` and `NIOLockedValueBox`; it has no NIO-free -// counterpart, so it is elided along with the rest of the SwiftNIO surface. -#if canImport(NIOCore) #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif +#if canImport(NIOCore) import NIOConcurrencyHelpers import NIOCore +#endif // canImport(NIOCore) import VaporCSQLite // MARK: - Hook Types and Events @@ -498,7 +497,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when update events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addUpdateObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteUpdateHookCallback) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .update, action: { $0.updateHooks[id] = callback }) @@ -529,7 +528,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when commit events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addCommitObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteCommitObserver) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .commit, action: { $0.commitObservers[id] = callback }) @@ -560,7 +559,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when commit events occur. /// - Returns: A ``SQLiteHookToken`` that removes the validator when canceled. public func setCommitValidator(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteCommitValidator) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.addHookAndInstallDispatcherIfNeeded(kind: .commit, action: { $0.commitValidator = callback }) return .init(lifetime: lifetime) { @Sendable [weak self = self] in _ = self?.removeHookAndUninstallDispatcherIfNeeded(kind: .commit, action: { $0.commitValidator = nil }) @@ -588,7 +587,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when rollback events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addRollbackObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteRollbackHookCallback) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .rollback, action: { $0.rollbackHooks[id] = callback }) @@ -619,7 +618,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when authorization events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addAuthorizerObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteAuthorizerObserver) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .authorizer, action: { $0.authorizerObservers[id] = callback }) @@ -653,7 +652,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when authorization events occur. /// - Returns: A ``SQLiteHookToken`` that removes the validator when canceled. public func setAuthorizerValidator(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteAuthorizerValidator) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.addHookAndInstallDispatcherIfNeeded(kind: .authorizer, action: { $0.authorizerValidator = callback }) return .init(lifetime: lifetime) { @Sendable [weak self = self] in _ = self?.removeHookAndUninstallDispatcherIfNeeded(kind: .commit, action: { $0.authorizerValidator = nil }) @@ -872,4 +871,3 @@ extension SQLiteConnection { self.observerBuckets.withLockedValue { $0 = .init() } } } -#endif // canImport(NIOCore) diff --git a/Sources/SQLiteNIO/SQLiteConnection.swift b/Sources/SQLiteNIO/SQLiteConnection.swift index bf38ee5..20f67e2 100644 --- a/Sources/SQLiteNIO/SQLiteConnection.swift +++ b/Sources/SQLiteNIO/SQLiteConnection.swift @@ -252,13 +252,13 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { /// The underlying `sqlite3` connection handle. let handle: SQLiteConnectionHandle - + + /// Container for storing multiple observers per hook type. + let observerBuckets = NIOLockedValueBox(.init()) + #if canImport(NIOCore) /// The thread pool used by this connection when calling libsqlite3 APIs. let threadPool: NIOThreadPool - - /// Container for storing multiple observers per hook type. - let observerBuckets = NIOLockedValueBox(.init()) /// Initialize a new ``SQLiteConnection``. Internal use only. private init( @@ -499,9 +499,7 @@ extension SQLiteConnection { /// No further operations may be performed on the connection after calling this method. public func close() async throws { try await self.withBlockingIO { - #if canImport(NIOCore) self.clearAllHooks() - #endif sqlite_nio_sqlite3_close(self.handle.raw) self.handle.raw = nil } From 9b7094d834e742585b82a44d88d91ee1dbafc9a8 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 28 Jul 2026 21:02:23 -0600 Subject: [PATCH 5/9] build: elide SwiftNIO on WASI Motivation: SwiftNIO cannot be built for `wasm32-unknown-wasip1`. Dependency resolution succeeds and NIOCore compiles, but NIOPosix does not: it is built on POSIX sockets and threads, neither of which WASI preview 1 provides. A WASI build of sqlite-nio therefore fails inside a dependency, before the conditional sources in the previous commit get a chance to matter. Modifications: Gate the SwiftNIO products on `.when(platforms: nonWASIPlatforms)`. Target dependency conditions are evaluated per platform, so on WASI the products are simply not linked and the `canImport(NIOCore)` gates select the `async` API. `NIOFoundationCompat` keeps its existing Darwin-only condition, which already excludes WASI. `.when(platforms:)` can only include, never exclude, so excluding one platform means enumerating the others; the list is the set SPM 6.1 knows about, noted as such so it is not extended without also raising the manifest's tools version. Gate the test suite's SwiftNIO imports on the same condition. Those tests drive connections through an `EventLoopGroup` and a `NIOThreadPool`, so they are only meaningful where SwiftNIO is present, and `swift build` evaluates `--explicit-target-dependency-import-check` for every target in the graph, including the test target it does not build. Result: On every other platform the resolved dependency set is byte-identical to before. On WASI the build graph contains no SwiftNIO module: not NIOPosix, not NIOCore, not NIOConcurrencyHelpers. `SQLITE_THREADSAFE` is left at `1` everywhere, since wasi-libc supplies the mutex primitives SQLite needs, and keeping serialized mode means a threaded WASI target is correct rather than silently unprotected. --- Package.swift | 18 +++++++++++++++--- .../SQLiteConnectionHookTests.swift | 2 ++ Tests/SQLiteNIOTests/SQLiteNIOTests.swift | 2 ++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index aaaa37e..26fcb52 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,13 @@ // swift-tools-version:6.1 import PackageDescription +/// `.when(platforms:)` can only include, never exclude, so excluding WASI means listing everything else. +/// This list matches the [supported platforms on the Swift 6.1 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/6.1/Sources/PackageDescription/SupportedPlatforms.swift#L35-L71); +/// don't add new platforms here unless raising the swift-tools-version of this manifest. +let nonWASIPlatforms: [Platform] = [ + .macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS, .driverKit, .linux, .windows, .android, .openbsd, +] + let package = Package( name: "sqlite-nio", platforms: [ @@ -37,11 +44,16 @@ let package = Package( dependencies: [ .target(name: "VaporCSQLite"), .product(name: "Logging", package: "swift-log"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), + // SwiftNIO does not support wasm32-unknown-wasip1: NIOPosix is built around POSIX + // sockets and threads, neither of which WASI preview 1 provides. On WASI these + // products are therefore not linked, and the `#if canImport(NIOCore)` gates in + // Sources/ drop the `EventLoopFuture` API in favor of the `async` one. + // (`NIOFoundationCompat` keeps its Darwin-only condition, which already excludes WASI.) + .product(name: "NIOCore", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), + .product(name: "NIOPosix", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst, .visionOS])), - .product(name: "NIOFoundationEssentialsCompat", package: "swift-nio"), + .product(name: "NIOFoundationEssentialsCompat", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), ], swiftSettings: swiftSettings ), diff --git a/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift b/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift index 8b74fe0..5ee613d 100644 --- a/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift @@ -1,6 +1,8 @@ import SQLiteNIO import Testing +#if canImport(NIOCore) import NIOConcurrencyHelpers +#endif @Suite("SQLite Connection Hook Tests") struct SQLiteConnectionHookTests { diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 63ad2ef..27b89e5 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -4,6 +4,7 @@ import FoundationEssentials import Foundation #endif import Logging +#if canImport(NIOCore) import NIOCore import NIOPosix #if canImport(FoundationEssentials) @@ -11,6 +12,7 @@ import NIOFoundationEssentialsCompat #else import NIOFoundationCompat #endif +#endif // canImport(NIOCore) import SQLiteNIO import Testing From ef86f0f5b32308d2ef1c40d451c265b99bd75bef Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 28 Jul 2026 21:02:42 -0600 Subject: [PATCH 6/9] ci: build for WebAssembly Motivation: `vapor/ci`'s reusable unit-test workflow already knows how to build a package for `wasm32-unknown-wasip1`; sqlite-nio simply had not opted in, so nothing stops the SwiftNIO-free configuration from regressing unnoticed. Modifications: Pass `with_wasm: true` to the reusable workflow, as sql-kit already does. Result: The WASI build is checked on every pull request. --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48b2792..4b44327 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: ios_scheme_name: sqlite-nio with_musl: true with_android: true + with_wasm: true submit-dependencies: permissions: From b5a192a1b7927955fd574d25339b6b7db534fc31 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Thu, 30 Jul 2026 15:40:41 -0600 Subject: [PATCH 7/9] fix: guard the single-threaded NIOLockedValueBox stand-in Motivation: The stand-in supplied where NIOCore cannot be imported takes no lock at all. That is only correct because the single target selecting that configuration today is single-threaded, and nothing in the source records the requirement, so a future target could pick up the configuration and silently get unsynchronized storage. Review asked for a guard that makes widening the configuration impossible without someone revisiting this type. Modifications: Add a `#if _runtime(_multithreaded)` tripwire above the type that fails the build with an explanatory diagnostic. The condition is threading rather than platform, because that is the actual requirement: `wasm32-unknown-wasip1` is single-threaded but `wasm32-unknown-wasip1-threads` is not, so a platform check would admit a target the type cannot serve. `NIOConcurrencyHelpers` keys its own lock implementations off the same condition. Add a debug-only reentrancy assertion to `withLockedValue(_:)`. Taking no lock also means the box cannot nest, and the hook dispatcher invokes callbacks synchronously from inside libsqlite3, so nesting is reachable rather than theoretical. Result: Where SwiftNIO is present, nothing changes: the tripwire sits inside the branch that is only compiled when NIOCore is absent. Where it is absent and the runtime is single-threaded, nothing changes either. A multithreaded target selecting this configuration now fails to compile with a message naming the fix. --- Sources/SQLiteNIO/Exports.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index e3a72d0..07d619d 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -46,6 +46,16 @@ extension ByteBuffer { } } +// N.B.: A tripwire, not a platform gate. The stand-in below takes no lock, so it only supports +// single-threaded use: `wasm32-unknown-wasip1` qualifies, `wasm32-unknown-wasip1-threads` does not. +#if _runtime(_multithreaded) +#error(""" + SQLiteNIO's `canImport(NIOCore)` fallback has been selected for a multithreaded runtime, and the \ + `NIOLockedValueBox` below does not support multithreaded locking. Please use a \ + multithreading-capable lock for this platform. + """) +#endif + /// A single-threaded stand-in for `NIOConcurrencyHelpers.NIOLockedValueBox`. /// /// The one supported SwiftNIO-free target is single-threaded, so no lock is needed and none is @@ -54,13 +64,17 @@ extension ByteBuffer { /// the same reason. final class NIOLockedValueBox: @unchecked Sendable { private var value: Value + private var isLocked = false init(_ value: Value) { self.value = value } func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { - try mutate(&self.value) + assert(!self.isLocked, "NIOLockedValueBox was re-entered unexpectedly; this implementation supports single-threaded use only") + self.isLocked = true + defer { self.isLocked = false } + return try mutate(&self.value) } } #endif // !canImport(NIOCore) From 608980b93da896a0b9dc0302176b37a6cfc9e11d Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Thu, 30 Jul 2026 16:24:12 -0600 Subject: [PATCH 8/9] test: cover lastAutoincrementID, function uninstall, and futures query Motivation: Consolidating the blocking-call helper rerouted several public entry points through `withBlockingIO(_:)`, and three of them had no test exercising them at all: `lastAutoincrementID()`, `uninstall(customFunction:)`, and the `EventLoopFuture` query primitive. The reshape was therefore unverified for those paths, and coverage reporting on the pull request flagged exactly those lines. Modifications: Add a test asserting `lastAutoincrementID()` reports the rowid of the most recent insert, and that it advances across two inserts. Add a test installing a custom function, calling it, uninstalling it, and asserting the call then fails. The assertion is against any error rather than a specific `SQLiteError` reason, because the failure originates in libsqlite3's unknown-function handling and pinning the reason would make the test brittle. Add a test driving the `EventLoopFuture` query surface end to end. It goes through the `[SQLiteRow]`-returning overload, which delegates to the `logger:`-taking primitive, so the legacy path is exercised without constructing a logger. Result: The suite goes from 59 tests to 62, all passing. The three previously unexercised lines now execute, and the reshaped `async` surface has direct coverage rather than relying on the paths that happened to be tested already. --- .../SQLiteCustomFunctionTests.swift | 13 +++++++++ Tests/SQLiteNIOTests/SQLiteNIOTests.swift | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift index ae591b9..1a328d1 100644 --- a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift @@ -271,6 +271,19 @@ struct DatabaseFunctionTests { } } + @Test + func uninstallRemovesFunction() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("removable", argumentCount: 0) { values in 1 } + try await conn.install(customFunction: fn) + #expect(try await Int(1) == conn.query("SELECT removable() as result").first?.column("result")?.integer) + + try await conn.uninstall(customFunction: fn) + + await #expect(throws: (any Error).self) { try await conn.query("SELECT removable()") } + } + } + // MARK: - setup init() { diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 27b89e5..46376ec 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -293,6 +293,33 @@ struct SQLiteNIOTests { } } + @Test + func lastAutoincrementIDTracksInserts() async throws { + try await withOpenedConnection { conn in + _ = try await conn.query("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") + _ = try await conn.query("INSERT INTO t (v) VALUES ('a')") + + #expect(try await conn.lastAutoincrementID() == 1) + + _ = try await conn.query("INSERT INTO t (v) VALUES ('b')") + + #expect(try await conn.lastAutoincrementID() == 2) + } + } + + @Test + func futuresQuerySurfaceReturnsRows() async throws { + try await withOpenedConnection { conn in + _ = try await conn.query("CREATE TABLE t (v TEXT)").get() + _ = try await conn.query("INSERT INTO t (v) VALUES (?)", [.text("a")]).get() + + let rows = try await conn.query("SELECT v FROM t").get() + + #expect(rows.count == 1) + #expect(try await "a" == rows.first?.column("v")?.string) + } + } + init() { #expect(isLoggingConfigured) } From 075bf2b8e6f28c8df56b09c4d3dbb146533f1342 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Thu, 30 Jul 2026 17:52:10 -0600 Subject: [PATCH 9/9] build: match the sibling manifests' platform-list idiom Motivation: This manifest spelled `nonWASIPlatforms` as a literal list that omits `.wasi`, while the other packages adopting the same approach derive it by filtering `.wasi` out of the full platform list. Review asked for the two to be consistent. The filter form is also the clearer of the two. It names the platform being excluded rather than leaving a reader to notice an absence, and it keeps a future edit from quietly reintroducing `.wasi` by appending it to the list. Modifications: Derive `nonWASIPlatforms` from `allPlatforms` by filtering. Drop the line anchor from the link to SPM's platform list and restore the sentence capitalization, so the whole comment block matches as well. Result: `swift package dump-package` is byte-identical before and after, so the resolved package description, and with it every target dependency condition, is unchanged. --- Package.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index 26fcb52..b2957cb 100644 --- a/Package.swift +++ b/Package.swift @@ -2,11 +2,10 @@ import PackageDescription /// `.when(platforms:)` can only include, never exclude, so excluding WASI means listing everything else. -/// This list matches the [supported platforms on the Swift 6.1 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/6.1/Sources/PackageDescription/SupportedPlatforms.swift#L35-L71); -/// don't add new platforms here unless raising the swift-tools-version of this manifest. -let nonWASIPlatforms: [Platform] = [ - .macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS, .driverKit, .linux, .windows, .android, .openbsd, -] +/// This list matches the [supported platforms on the Swift 6.1 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/6.1/Sources/PackageDescription/SupportedPlatforms.swift). +/// Don't add new platforms here unless raising the swift-tools-version of this manifest. +let allPlatforms: [Platform] = [.macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS, .driverKit, .linux, .windows, .android, .wasi, .openbsd] +let nonWASIPlatforms: [Platform] = allPlatforms.filter { $0 != .wasi } let package = Package( name: "sqlite-nio",