From 4fd5e1062ca0a7fd601c98d5e519f408e9c44bfc Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 6 Aug 2026 17:38:34 -0300 Subject: [PATCH 1/5] feat(storage): add vector bucket CRUD (alpha) Implements the storage.vectors client by hand, using the same StorageApi/StorageHTTPSession HTTP stack as the rest of the Storage module, instead of a generated OpenAPI client. --- Sources/Storage/StorageVectorsClient.swift | 186 ++++++++++++++++++ Sources/Storage/SupabaseStorage.swift | 14 ++ .../StorageVectorsClientTests.swift | 136 +++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 Sources/Storage/StorageVectorsClient.swift create mode 100644 Tests/StorageTests/StorageVectorsClientTests.swift diff --git a/Sources/Storage/StorageVectorsClient.swift b/Sources/Storage/StorageVectorsClient.swift new file mode 100644 index 000000000..990ebf3bc --- /dev/null +++ b/Sources/Storage/StorageVectorsClient.swift @@ -0,0 +1,186 @@ +// +// StorageVectorsClient.swift +// Storage +// +// Created by Guilherme Souza on 27/07/26. +// + +import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client for managing Supabase Storage's alpha "vector buckets" feature (`storage.vectors`). +/// +/// Obtain an instance via ``SupabaseStorageClient/vectors``: +/// +/// ```swift +/// try await client.storage.vectors.createBucket("documents") +/// let buckets = try await client.storage.vectors.listBuckets().vectorBuckets +/// ``` +/// +/// - Warning: Vector buckets are a public alpha feature of Supabase Storage and this API is +/// experimental — it may change in a breaking way, or be unavailable on your project, until it +/// reaches general availability. Opt in with `@_spi(Experimental) import Supabase`. +/// +/// ## Topics +/// +/// ### Managing vector buckets +/// +/// - ``createBucket(_:)`` +/// - ``getBucket(_:)`` +/// - ``listBuckets(prefix:maxResults:nextToken:)`` +/// - ``deleteBucket(_:)`` +@_spi(Experimental) +public class StorageVectorsClient: StorageApi, @unchecked Sendable { + /// Creates a new vector bucket. + /// + /// ```swift + /// try await client.storage.vectors.createBucket("documents") + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to create. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createBucket(_ name: String) async throws { + try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("vector/CreateVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + } + + /// Retrieves the details of an existing vector bucket. + /// + /// ```swift + /// let bucket = try await client.storage.vectors.getBucket("documents") + /// print(bucket.vectorBucketName) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to fetch. + /// - Returns: The matching ``VectorBucket``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getBucket(_ name: String) async throws -> VectorBucket { + let response: GetVectorBucketResponseBody = try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("vector/GetVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + .decoded(decoder: .supabase()) + return response.vectorBucket + } + + /// Lists the vector buckets in the project, optionally filtered by name prefix. + /// + /// Results are paginated: pass the ``ListVectorBucketsResponse/nextToken`` of a previous response + /// as `nextToken` to fetch the following page. + /// + /// ```swift + /// let page = try await client.storage.vectors.listBuckets(prefix: "docs") + /// for bucket in page.vectorBuckets { + /// print(bucket.vectorBucketName) + /// } + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - prefix: Returns only buckets whose name starts with this prefix. Pass `nil` for all buckets. + /// - maxResults: The maximum number of buckets to return in this page. + /// - nextToken: The pagination token from a previous response. + /// - Returns: A page of buckets, plus the token for the next page when more results exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listBuckets( + prefix: String? = nil, + maxResults: Int? = nil, + nextToken: String? = nil + ) async throws -> ListVectorBucketsResponse { + let response: ListVectorBucketsResponseBody = try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("vector/ListVectorBuckets"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + VectorBucketListBody(maxResults: maxResults, nextToken: nextToken, prefix: prefix) + ) + ) + ) + .decoded(decoder: .supabase()) + return ListVectorBucketsResponse( + vectorBuckets: response.vectorBuckets, + nextToken: response.nextToken + ) + } + + /// Deletes a vector bucket. + /// + /// ```swift + /// try await client.storage.vectors.deleteBucket("documents") + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteBucket(_ name: String) async throws { + try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("vector/DeleteVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + } +} + +private struct VectorBucketNameBody: Encodable { + var vectorBucketName: String +} + +private struct VectorBucketListBody: Encodable { + var maxResults: Int? + var nextToken: String? + var prefix: String? +} + +private struct GetVectorBucketResponseBody: Decodable { + var vectorBucket: VectorBucket +} + +private struct ListVectorBucketsResponseBody: Decodable { + var vectorBuckets: [VectorBucket] + var nextToken: String? +} + +/// A vector bucket, as returned by ``StorageVectorsClient``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorBucket: Codable, Sendable, Hashable { + /// The name of the vector bucket. + public var vectorBucketName: String + + /// Unix timestamp (seconds) of when the bucket was created, if known. + public var creationTime: Int? +} + +/// A page of vector buckets, as returned by +/// ``StorageVectorsClient/listBuckets(prefix:maxResults:nextToken:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct ListVectorBucketsResponse: Sendable { + /// The buckets in this page. + public var vectorBuckets: [VectorBucket] + + /// The pagination token to pass to fetch the next page, or `nil` when there are no more results. + public var nextToken: String? +} diff --git a/Sources/Storage/SupabaseStorage.swift b/Sources/Storage/SupabaseStorage.swift index aab07265e..ffdad6df6 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -103,6 +103,7 @@ public struct StorageClientConfiguration: Sendable { /// ### Accessing buckets /// /// - ``from(_:)`` +/// - ``vectors`` /// /// ### Bucket management /// @@ -123,4 +124,17 @@ public class SupabaseStorageClient: StorageBucketApi, @unchecked Sendable { public func from(_ id: String) -> StorageFileApi { StorageFileApi(bucketId: id, configuration: configuration) } + + /// A client for managing vector buckets. + /// + /// ```swift + /// try await client.storage.vectors.createBucket("documents") + /// let buckets = try await client.storage.vectors.listBuckets().vectorBuckets + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + @_spi(Experimental) + public var vectors: StorageVectorsClient { + StorageVectorsClient(configuration: configuration) + } } diff --git a/Tests/StorageTests/StorageVectorsClientTests.swift b/Tests/StorageTests/StorageVectorsClientTests.swift new file mode 100644 index 000000000..97e3795e2 --- /dev/null +++ b/Tests/StorageTests/StorageVectorsClientTests.swift @@ -0,0 +1,136 @@ +// +// StorageVectorsClientTests.swift +// Storage +// +// Created by Guilherme Souza on 27/07/26. +// +import Foundation +import Mocker +import TestHelpers +import Testing + +@_spi(Experimental) @testable import Storage + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension StorageMockerTests { + @Suite(.mockerSerialized) + struct StorageVectorsClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> StorageVectorsClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return StorageVectorsClient( + configuration: StorageClientConfiguration( + url: url, + headers: [:], + session: StorageHTTPSession( + fetch: { try await session.data(for: $0) }, + upload: { try await session.upload(for: $0, from: $1) } + ), + logger: nil + ) + ) + } + + @Test + func createBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateVectorBucket"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await vectors.createBucket("documents") + } + + @Test + func deleteBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/DeleteVectorBucket"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await vectors.deleteBucket("documents") + } + + @Test + func getBucketDecodesVectorBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/GetVectorBucket"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectorBucket":{"vectorBucketName":"documents","creationTime":1730000000}} + """.utf8 + ) + ] + ).register() + + let bucket = try await vectors.getBucket("documents") + #expect(bucket.vectorBucketName == "documents") + #expect(bucket.creationTime == 1_730_000_000) + } + + @Test + func listBucketsDecodesBucketsAndNextToken() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/ListVectorBuckets"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectorBuckets":[{"vectorBucketName":"documents"},{"vectorBucketName":"images"}],\ + "nextToken":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await vectors.listBuckets(prefix: "doc", maxResults: 2) + #expect(response.vectorBuckets.map(\.vectorBucketName) == ["documents", "images"]) + #expect(response.nextToken == "page-2") + } + + @Test + func forbiddenThrowsStorageError() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateVectorBucket"), + statusCode: 403, + data: [ + .post: Data( + """ + {"code":"403","error":"Unauthorized","message":"new row violates row-level security",\ + "statusCode":"403"} + """.utf8 + ) + ] + ).register() + + let error = await #expect(throws: StorageError.self) { + try await vectors.createBucket("documents") + } + #expect(error?.message == "new row violates row-level security") + #expect(error?.error == "Unauthorized") + } + } +} From eae7b0619b393314fb7a35a85749a4279d60127f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 6 Aug 2026 17:48:20 -0300 Subject: [PATCH 2/5] test(storage): add integration tests for vector bucket CRUD Verified against a local Supabase stack (supabase start): create, get, list (incl. prefix filtering), and delete, plus the 404 error shape for a missing bucket. --- ...StorageVectorsClientIntegrationTests.swift | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift diff --git a/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift b/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift new file mode 100644 index 000000000..479a88695 --- /dev/null +++ b/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift @@ -0,0 +1,85 @@ +// +// StorageVectorsClientIntegrationTests.swift +// +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +import InlineSnapshotTesting +@_spi(Experimental) import Storage +import Testing + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil)) +struct StorageVectorsClientIntegrationTests { + let vectors = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_SECRET_KEY)" + ], + logger: nil + ) + ).vectors + + init() async throws { + // Clean up test-vector-bucket if it exists from a previous failed run + // to make tests idempotent + try? await vectors.deleteBucket("test-vector-bucket") + } + + @Test + func vectorBucket_CRUD() async throws { + let bucketName = "test-vector-bucket" + + var page = try await vectors.listBuckets() + #expect(!page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.createBucket(bucketName) + + let bucket = try await vectors.getBucket(bucketName) + #expect(bucket.vectorBucketName == bucketName) + + page = try await vectors.listBuckets() + #expect(page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.deleteBucket(bucketName) + + page = try await vectors.listBuckets() + #expect(!page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + } + + @Test + func listBucketsWithPrefix() async throws { + let bucketName = "test-vector-bucket" + try await vectors.createBucket(bucketName) + + let matching = try await vectors.listBuckets(prefix: "test-vector-") + #expect(matching.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + let nonMatching = try await vectors.listBuckets(prefix: "no-such-prefix-") + #expect(!nonMatching.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.deleteBucket(bucketName) + } + + @Test + func getBucketWithWrongName() async { + do { + _ = try await vectors.getBucket("not-exist-bucket") + Issue.record("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "NotFoundException" + - message: "resource \\"not-exist-bucket\\" not found" + ▿ statusCode: Optional + - some: "404" + + """ + } + } + } +} From 887df6504af34d1f02e867db4f9e2a57b2394920 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 6 Aug 2026 18:14:48 -0300 Subject: [PATCH 3/5] fix(storage): use TimeInterval for VectorBucket.creationTime The wire value is a raw UNIX timestamp (seconds), not an ISO8601 string, so Int obscured the semantics. Matches Session.expiresAt's existing TimeInterval convention for UNIX timestamps in this codebase. --- Sources/Storage/StorageVectorsClient.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Storage/StorageVectorsClient.swift b/Sources/Storage/StorageVectorsClient.swift index 990ebf3bc..768b05304 100644 --- a/Sources/Storage/StorageVectorsClient.swift +++ b/Sources/Storage/StorageVectorsClient.swift @@ -5,7 +5,7 @@ // Created by Guilherme Souza on 27/07/26. // -import Foundation +public import Foundation import HTTPTypes #if canImport(FoundationNetworking) @@ -168,8 +168,8 @@ public struct VectorBucket: Codable, Sendable, Hashable { /// The name of the vector bucket. public var vectorBucketName: String - /// Unix timestamp (seconds) of when the bucket was created, if known. - public var creationTime: Int? + /// UNIX timestamp (seconds) of when the bucket was created, if known. + public var creationTime: TimeInterval? } /// A page of vector buckets, as returned by From 1faa2d540fec901b3bae9f1596c31961b18d1556 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 6 Aug 2026 19:14:27 -0300 Subject: [PATCH 4/5] refactor(storage): make StorageVectorsClient a struct over StorageApi Composition instead of subclassing StorageApi: the client now holds a StorageApi dependency passed in at init, rather than inheriting execute() and configuration directly. SupabaseStorageClient.vectors now passes itself as that dependency, so custom headers set via setHeader() are shared between the main client and .vectors. --- Sources/Storage/StorageVectorsClient.swift | 24 ++++++++++++------- Sources/Storage/SupabaseStorage.swift | 2 +- .../StorageVectorsClientTests.swift | 18 +++++++------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/Sources/Storage/StorageVectorsClient.swift b/Sources/Storage/StorageVectorsClient.swift index 768b05304..080563252 100644 --- a/Sources/Storage/StorageVectorsClient.swift +++ b/Sources/Storage/StorageVectorsClient.swift @@ -34,7 +34,13 @@ import HTTPTypes /// - ``listBuckets(prefix:maxResults:nextToken:)`` /// - ``deleteBucket(_:)`` @_spi(Experimental) -public class StorageVectorsClient: StorageApi, @unchecked Sendable { +public struct StorageVectorsClient: Sendable { + private let api: StorageApi + + init(api: StorageApi) { + self.api = api + } + /// Creates a new vector bucket. /// /// ```swift @@ -46,9 +52,9 @@ public class StorageVectorsClient: StorageApi, @unchecked Sendable { /// - Parameter name: The name of the vector bucket to create. /// - Throws: ``StorageError`` when the API rejects the request. public func createBucket(_ name: String) async throws { - try await execute( + try await api.execute( HTTPRequest( - url: configuration.url.appendingPathComponent("vector/CreateVectorBucket"), + url: api.configuration.url.appendingPathComponent("vector/CreateVectorBucket"), method: .post, body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) ) @@ -68,9 +74,9 @@ public class StorageVectorsClient: StorageApi, @unchecked Sendable { /// - Returns: The matching ``VectorBucket``. /// - Throws: ``StorageError`` when the API rejects the request. public func getBucket(_ name: String) async throws -> VectorBucket { - let response: GetVectorBucketResponseBody = try await execute( + let response: GetVectorBucketResponseBody = try await api.execute( HTTPRequest( - url: configuration.url.appendingPathComponent("vector/GetVectorBucket"), + url: api.configuration.url.appendingPathComponent("vector/GetVectorBucket"), method: .post, body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) ) @@ -104,9 +110,9 @@ public class StorageVectorsClient: StorageApi, @unchecked Sendable { maxResults: Int? = nil, nextToken: String? = nil ) async throws -> ListVectorBucketsResponse { - let response: ListVectorBucketsResponseBody = try await execute( + let response: ListVectorBucketsResponseBody = try await api.execute( HTTPRequest( - url: configuration.url.appendingPathComponent("vector/ListVectorBuckets"), + url: api.configuration.url.appendingPathComponent("vector/ListVectorBuckets"), method: .post, body: JSONEncoder.unconfiguredEncoder.encode( VectorBucketListBody(maxResults: maxResults, nextToken: nextToken, prefix: prefix) @@ -131,9 +137,9 @@ public class StorageVectorsClient: StorageApi, @unchecked Sendable { /// - Parameter name: The name of the vector bucket to delete. /// - Throws: ``StorageError`` when the API rejects the request. public func deleteBucket(_ name: String) async throws { - try await execute( + try await api.execute( HTTPRequest( - url: configuration.url.appendingPathComponent("vector/DeleteVectorBucket"), + url: api.configuration.url.appendingPathComponent("vector/DeleteVectorBucket"), method: .post, body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) ) diff --git a/Sources/Storage/SupabaseStorage.swift b/Sources/Storage/SupabaseStorage.swift index ffdad6df6..3831ed0ed 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -135,6 +135,6 @@ public class SupabaseStorageClient: StorageBucketApi, @unchecked Sendable { /// - Warning: Experimental. See ``StorageVectorsClient``. @_spi(Experimental) public var vectors: StorageVectorsClient { - StorageVectorsClient(configuration: configuration) + StorageVectorsClient(api: self) } } diff --git a/Tests/StorageTests/StorageVectorsClientTests.swift b/Tests/StorageTests/StorageVectorsClientTests.swift index 97e3795e2..56304f1a7 100644 --- a/Tests/StorageTests/StorageVectorsClientTests.swift +++ b/Tests/StorageTests/StorageVectorsClientTests.swift @@ -28,14 +28,16 @@ extension StorageMockerTests { let session = URLSession(configuration: configuration) return StorageVectorsClient( - configuration: StorageClientConfiguration( - url: url, - headers: [:], - session: StorageHTTPSession( - fetch: { try await session.data(for: $0) }, - upload: { try await session.upload(for: $0, from: $1) } - ), - logger: nil + api: StorageApi( + configuration: StorageClientConfiguration( + url: url, + headers: [:], + session: StorageHTTPSession( + fetch: { try await session.data(for: $0) }, + upload: { try await session.upload(for: $0, from: $1) } + ), + logger: nil + ) ) ) } From bc2a5556d8a3c72880ba85e7f216815258e8e8a5 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 7 Aug 2026 05:09:44 -0300 Subject: [PATCH 5/5] chore: trigger CI