From f16647dca2462c451ba24dcf2d97122728407c93 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Mon, 27 Jul 2026 17:08:32 -0600 Subject: [PATCH 1/3] feat: make the SwiftNIO and AsyncKit surface conditional Motivation: Neither SwiftNIO nor AsyncKit can be built for `wasm32-unknown-wasip1`: NIOPosix is built on POSIX sockets and threads, neither of which WASI preview 1 provides, and AsyncKit's connection pool is built on NIOPosix in turn. SQLiteKit uses them for the connection pool and for the legacy `EventLoopFuture` half of an API whose `async` half is already the recommended one, so it can build without them, given somewhere to put the differences. Modifications: Gate the affected declarations with `#if canImport(...)`, keyed on the module that actually supplies each API rather than on a platform, so the sources stay in step with whatever the manifest resolves for the target being built and with the gates sqlite-nio and sql-kit apply to the same dependencies. What drops out where the modules are absent: - `SQLiteConnectionSource`, a `ConnectionPoolSource` over AsyncKit and `NIOThreadPool`, drops out entirely, along with the `AsyncKit` re-export. Callers use sqlite-nio's concrete `async` `SQLiteConnection` directly; there is no connection pool on those platforms. - The `SQLDatabase` wrapper's `eventLoop` and its `execute(sql:_:) -> EventLoopFuture` overload drop out. SQLKit removes both as protocol requirements on the same platforms. `withSession(_:)` stays: its signature names no NIO or AsyncKit type, and the async `withConnection` it calls is an unconditional requirement of sqlite-nio's `SQLiteDatabase`. - `SQLiteDataDecoder`'s JSON fallback reads a `[UInt8]` blob rather than a `ByteBuffer`, matching `SQLiteData`'s representation there. The decoder's two blob cases stay forked rather than collapsing to the `[UInt8]` form on both: `Data.init(buffer:byteTransferStrategy:)` is a zero-copy bridge with no `[UInt8]` equivalent, so collapsing them would silently add a copy where SwiftNIO is present. The gate names NIOFoundationCompat, which is the module that declares that initializer. `SQLiteDataEncoder`'s `import NIOCore` is dropped rather than gated: the file references no NIOCore symbol, so the import was already dead. Result: Where SwiftNIO and AsyncKit are available the public API and the symbol graph are unchanged, the same symbols with the same `docComment` line counts, and `diagnose-api-breaking-changes` reports no differences. Where they are absent SQLiteKit compiles down to the pool-free, `async`-only surface. --- Sources/SQLiteKit/Exports.swift | 3 +++ Sources/SQLiteKit/SQLiteConnection+SQLKit.swift | 6 ++++++ Sources/SQLiteKit/SQLiteConnectionSource.swift | 5 +++++ Sources/SQLiteKit/SQLiteDataDecoder.swift | 10 ++++++++++ Sources/SQLiteKit/SQLiteDataEncoder.swift | 1 - 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteKit/Exports.swift b/Sources/SQLiteKit/Exports.swift index 6656d7a..f4b1639 100644 --- a/Sources/SQLiteKit/Exports.swift +++ b/Sources/SQLiteKit/Exports.swift @@ -1,4 +1,7 @@ @_documentation(visibility: internal) @_exported import SQLKit @_documentation(visibility: internal) @_exported import SQLiteNIO +// AsyncKit, and with it the connection pool, is gated to the same platforms as SwiftNIO (see Package.swift). +#if canImport(AsyncKit) @_documentation(visibility: internal) @_exported import AsyncKit +#endif @_documentation(visibility: internal) @_exported import struct Logging.Logger diff --git a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift index 5bd69ce..fd73b72 100644 --- a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift +++ b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift @@ -173,11 +173,13 @@ struct SQLiteDatabaseVersion: SQLDatabaseReportedVersion { @usableFromInline let decoder: SQLiteDataDecoder + #if canImport(NIOCore) // See `SQLDatabase.eventLoop`. @usableFromInline var eventLoop: any EventLoop { self.database.eventLoop } + #endif // See `SQLDatabase.version`. @usableFromInline @@ -209,6 +211,9 @@ struct SQLiteDatabaseVersion: SQLDatabaseReportedVersion { self.queryLogLevel = queryLogLevel } + // The `EventLoopFuture` overload only exists where SwiftNIO does; SQLKit's protocol drops the + // requirement on the other platforms and the `async` overload below carries the whole surface. + #if canImport(NIOCore) // See `SQLDatabase.execute(sql:_:)`. @usableFromInline func execute( @@ -234,6 +239,7 @@ struct SQLiteDatabaseVersion: SQLDatabaseReportedVersion { { onRow($0.sql(decoder: self.decoder)) } ) } + #endif // canImport(NIOCore) // See `SQLDatabase.execute(sql:_:)`. @usableFromInline diff --git a/Sources/SQLiteKit/SQLiteConnectionSource.swift b/Sources/SQLiteKit/SQLiteConnectionSource.swift index e327a50..c3026de 100644 --- a/Sources/SQLiteKit/SQLiteConnectionSource.swift +++ b/Sources/SQLiteKit/SQLiteConnectionSource.swift @@ -1,3 +1,7 @@ +// ``SQLiteConnectionSource`` is a `ConnectionPoolSource`, built on AsyncKit and SwiftNIO's thread +// pool. Neither is available on every platform SQLiteKit supports; where they are absent, callers +// use sqlite-nio's concrete async `SQLiteConnection` directly and there is no pool. +#if canImport(AsyncKit) #if canImport(Darwin) import Foundation #else @@ -97,3 +101,4 @@ fileprivate extension SQLiteConfiguration.Storage { } } } +#endif // canImport(AsyncKit) diff --git a/Sources/SQLiteKit/SQLiteDataDecoder.swift b/Sources/SQLiteKit/SQLiteDataDecoder.swift index 68fe89a..cbcb7dc 100644 --- a/Sources/SQLiteKit/SQLiteDataDecoder.swift +++ b/Sources/SQLiteKit/SQLiteDataDecoder.swift @@ -1,7 +1,9 @@ import Foundation import SQLiteNIO @_spi(CodableUtilities) import SQLKit +#if canImport(NIOFoundationCompat) import NIOFoundationCompat +#endif /// Translates `SQLiteData` values received from the database into `Decodable` values. /// @@ -51,7 +53,15 @@ public struct SQLiteDataDecoder: Sendable { switch data { case .text(let str): buf = .init(str.utf8) + // N.B.: `Data.init(buffer:byteTransferStrategy:)` is NIOFoundationCompat's + // zero-copy bridge and has no `[UInt8]` equivalent. The two cases stay forked + // rather than collapsing onto a copying spelling such as + // `Data(blob.readableBytesView)`, which would add a copy where SwiftNIO is present. + #if canImport(NIOFoundationCompat) case .blob(let blob): buf = .init(buffer: blob, byteTransferStrategy: .noCopy) + #else + case .blob(let blob): buf = .init(blob) // `SQLiteData.blob` carries `[UInt8]` without SwiftNIO + #endif // The remaining cases should never happen, but we implement them anyway just in case. case .integer(let n): buf = .init(String(n).utf8) case .float(let n): buf = .init(String(n).utf8) diff --git a/Sources/SQLiteKit/SQLiteDataEncoder.swift b/Sources/SQLiteKit/SQLiteDataEncoder.swift index 97e7ed3..b483eef 100644 --- a/Sources/SQLiteKit/SQLiteDataEncoder.swift +++ b/Sources/SQLiteKit/SQLiteDataEncoder.swift @@ -1,4 +1,3 @@ -import NIOCore import Foundation @_spi(CodableUtilities) import SQLKit import SQLiteNIO From 56f67dac301ceee2b0c5578a1ef27990f6e3b30a Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Mon, 27 Jul 2026 15:13:56 -0600 Subject: [PATCH 2/3] build: elide SwiftNIO and AsyncKit on WASI Motivation: AsyncKit cannot be built for `wasm32-unknown-wasip1`: its connection pool rides NIOPosix, which needs the POSIX sockets and threads WASI preview 1 lacks. NIOCore does build there, but sqlite-nio's WASI flavor is SwiftNIO-free, so a linked NIOCore would flip the `canImport(NIOCore)` gates against a SQLiteNIO that has no event loops, and NIOFoundationCompat would have no `ByteBuffer` to bridge. A WASI build therefore fails inside a dependency, before the conditional sources in the previous commit get a chance to matter. Modifications: Gate the NIOFoundationCompat and AsyncKit products on `.when(platforms: nonWASIPlatforms)` in both manifests. Target dependency conditions are evaluated per platform, so on WASI the products are simply not linked and the `canImport(...)` gates select the pool-free `async` API. `.when(platforms:)` can only include, never exclude, so excluding one platform means enumerating the others; each list is the set the manifest's own tools version knows about, noted as such so it is not extended without also raising that version. The tools-5.8 list omits `.visionOS`, which SPM only gained in 5.9; toolchains that know that platform read `Package@swift-5.9.swift` instead. Result: On every other platform the resolved dependency set is byte-identical to before. On WASI the build graph contains no SwiftNIO and no AsyncKit module. --- Package.swift | 18 ++++++++++++++++-- Package@swift-5.9.swift | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index 3d1b537..fb607ca 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,13 @@ // swift-tools-version:5.8 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 5.8 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/5.8/Sources/PackageDescription/SupportedPlatforms.swift); +/// `.visionOS` arrived in SPM 5.9, and toolchains that know it read `Package@swift-5.9.swift` instead. +/// Don't add new platforms here unless raising the swift-tools-version of this manifest. +let allPlatforms: [Platform] = [.macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .driverKit, .linux, .windows, .android, .wasi, .openbsd] +let nonWASIPlatforms: [Platform] = allPlatforms.filter { $0 != .wasi } + let package = Package( name: "sqlite-kit", platforms: [ @@ -22,8 +29,15 @@ let package = Package( .target( name: "SQLiteKit", dependencies: [ - .product(name: "NIOFoundationCompat", package: "swift-nio"), - .product(name: "AsyncKit", package: "async-kit"), + // Target dependency conditions are evaluated per platform; on WASI these two + // products are not linked. AsyncKit's pool rides NIOPosix, which needs the POSIX + // sockets and threads WASI preview 1 lacks, and sqlite-nio's WASI flavor is + // SwiftNIO-free, so NIOFoundationCompat would have nothing to bridge and a linked + // NIOCore would flip the `#if canImport(NIOCore)` gates against a SQLiteNIO with no + // event loops. SQLiteKit then compiles without the connection pool and without the + // EventLoopFuture surface (see the `#if canImport(...)` gates in Sources/). + .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), + .product(name: "AsyncKit", package: "async-kit", condition: .when(platforms: nonWASIPlatforms)), .product(name: "SQLiteNIO", package: "sqlite-nio"), .product(name: "SQLKit", package: "sql-kit"), ], diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index ae4fbb4..82b6383 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -1,6 +1,12 @@ // swift-tools-version:5.9 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 5.9 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/5.9/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-kit", platforms: [ @@ -22,8 +28,15 @@ let package = Package( .target( name: "SQLiteKit", dependencies: [ - .product(name: "NIOFoundationCompat", package: "swift-nio"), - .product(name: "AsyncKit", package: "async-kit"), + // Target dependency conditions are evaluated per platform; on WASI these two + // products are not linked. AsyncKit's pool rides NIOPosix, which needs the POSIX + // sockets and threads WASI preview 1 lacks, and sqlite-nio's WASI flavor is + // SwiftNIO-free, so NIOFoundationCompat would have nothing to bridge and a linked + // NIOCore would flip the `#if canImport(NIOCore)` gates against a SQLiteNIO with no + // event loops. SQLiteKit then compiles without the connection pool and without the + // EventLoopFuture surface (see the `#if canImport(...)` gates in Sources/). + .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), + .product(name: "AsyncKit", package: "async-kit", condition: .when(platforms: nonWASIPlatforms)), .product(name: "SQLiteNIO", package: "sqlite-nio"), .product(name: "SQLKit", package: "sql-kit"), ], From 36e0e3bc6b3391c4e65362c41a88c665f6877171 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Mon, 27 Jul 2026 17:08:45 -0600 Subject: [PATCH 3/3] ci: build for WebAssembly Motivation: `vapor/ci`'s reusable unit-test workflow already knows how to build a package for `wasm32-unknown-wasip1`; sqlite-kit 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 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27ce87c..995942f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,8 @@ jobs: contents: read uses: vapor/ci/.github/workflows/run-unit-tests.yml@main secrets: inherit + with: + with_wasm: true # Make sure downstream dependents still work dependents-check: