diff --git a/Sources/Storage/AnalyticsBucketClient.swift b/Sources/Storage/AnalyticsBucketClient.swift new file mode 100644 index 000000000..5f7c91451 --- /dev/null +++ b/Sources/Storage/AnalyticsBucketClient.swift @@ -0,0 +1,275 @@ +// +// AnalyticsBucketClient.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client scoped to a single analytics bucket, for managing its Iceberg namespaces and tables. +/// +/// Obtain an instance via ``AnalyticsClient/from(_:)``: +/// +/// ```swift +/// let bucket = client.storage.analytics.from("events") +/// try await bucket.createNamespace("default") +/// let namespace = bucket.namespace("default") +/// ``` +/// +/// - Warning: Analytics 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 namespaces +/// +/// - ``createNamespace(_:properties:)`` +/// - ``getNamespace(_:)`` +/// - ``listNamespaces(parent:pageSize:pageToken:)`` +/// - ``namespaceExists(_:)`` +/// - ``deleteNamespace(_:)`` +/// +/// ### Accessing tables +/// +/// - ``namespace(_:)`` +@_spi(Experimental) +public struct AnalyticsBucketClient: Sendable { + /// The name of the analytics bucket this client operates on. + public let bucketName: String + + private let api: StorageApi + + init(bucketName: String, api: StorageApi) { + self.bucketName = bucketName + self.api = api + } + + /// Creates a new namespace in this bucket. + /// + /// ```swift + /// try await bucket.createNamespace("default") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - name: The name of the namespace to create. + /// - properties: Arbitrary key-value properties to store with the namespace. + /// - Returns: The created ``IcebergNamespace``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createNamespace( + _ name: String, + properties: [String: String]? = nil + ) async throws -> IcebergNamespace { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("iceberg/v1/\(bucketName)/namespaces"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + CreateNamespaceBody(namespace: name, properties: properties) + ) + ) + ) + .decoded(decoder: .supabase()) + } + + /// Retrieves the properties of an existing namespace. + /// + /// ```swift + /// let namespace = try await bucket.getNamespace("default") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the namespace to retrieve. + /// - Returns: The matching ``IcebergNamespace``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getNamespace(_ name: String) async throws -> IcebergNamespace { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(name)"), + method: .get + ) + ) + .decoded(decoder: .supabase()) + } + + /// Lists the namespaces in this bucket. + /// + /// Results are paginated: pass the ``ListIcebergNamespacesResponse/nextPageToken`` of a previous + /// response as `pageToken` to fetch the following page. + /// + /// ```swift + /// let page = try await bucket.listNamespaces() + /// for namespace in page.namespaces { + /// print(namespace) + /// } + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - parent: Returns only namespaces nested under this parent namespace. + /// - pageSize: The maximum number of namespaces to return in this page. + /// - pageToken: The pagination token from a previous response. + /// - Returns: A page of namespace names, plus the token for the next page when more results + /// exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listNamespaces( + parent: String? = nil, + pageSize: Int? = nil, + pageToken: String? = nil + ) async throws -> ListIcebergNamespacesResponse { + var query: [URLQueryItem] = [] + if let parent { query.append(URLQueryItem(name: "parent", value: parent)) } + if let pageSize { query.append(URLQueryItem(name: "pageSize", value: String(pageSize))) } + if let pageToken { query.append(URLQueryItem(name: "pageToken", value: pageToken)) } + + let response: ListNamespacesResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("iceberg/v1/\(bucketName)/namespaces"), + method: .get, + query: query + ) + ) + .decoded(decoder: .supabase()) + return ListIcebergNamespacesResponse( + namespaces: response.namespaces.compactMap(\.first), + nextPageToken: response.nextPageToken + ) + } + + /// Checks whether a namespace exists in this bucket. + /// + /// ```swift + /// if try await bucket.namespaceExists("default") { + /// // ... + /// } + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the namespace to check. + /// - Returns: `true` if the namespace exists, `false` otherwise. + /// - Throws: ``StorageError`` for any failure other than the namespace not existing. + public func namespaceExists(_ name: String) async throws -> Bool { + do { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(name)"), + method: .head + ) + ) + return true + } catch let error as StorageError { + if let statusCode = error.statusCode.flatMap(Int.init), [400, 404].contains(statusCode) { + return false + } + throw error + } + } + + /// Deletes a namespace. + /// + /// > Important: A namespace cannot be deleted while it contains tables. Drop all tables first. + /// + /// ```swift + /// try await bucket.deleteNamespace("default") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the namespace to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteNamespace(_ name: String) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(name)"), + method: .delete + ) + ) + } + + /// Returns a client scoped to the given namespace, for managing its Iceberg tables. + /// + /// ```swift + /// let namespace = client.storage.analytics.from("events").namespace("default") + /// try await namespace.listTables() + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the namespace. + /// - Returns: An ``IcebergNamespaceClient`` configured for the given namespace. + public func namespace(_ name: String) -> IcebergNamespaceClient { + IcebergNamespaceClient( + bucketName: bucketName, + namespaceName: name, + api: api + ) + } +} + +private struct CreateNamespaceBody: Encodable { + var namespace: String + var properties: [String: String]? +} + +private struct ListNamespacesResponseBody: Decodable { + var namespaces: [[String]] + var nextPageToken: String? + + private enum CodingKeys: String, CodingKey { + case namespaces + case nextPageToken = "next-page-token" + } +} + +/// An Iceberg namespace, as returned by ``AnalyticsBucketClient``. +/// +/// - Warning: Experimental. See ``AnalyticsClient``. +@_spi(Experimental) +public struct IcebergNamespace: Sendable, Hashable { + /// The name of the namespace. + public var name: String + + /// Arbitrary key-value properties stored with the namespace. + public var properties: [String: String]? +} + +extension IcebergNamespace: Decodable { + private enum CodingKeys: String, CodingKey { + case namespace + case properties + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let namespace = try container.decode([String].self, forKey: .namespace) + name = namespace.first ?? "" + properties = try container.decodeIfPresent([String: String].self, forKey: .properties) + } +} + +/// A page of namespace names, as returned by +/// ``AnalyticsBucketClient/listNamespaces(parent:pageSize:pageToken:)``. +/// +/// - Warning: Experimental. See ``AnalyticsClient``. +@_spi(Experimental) +public struct ListIcebergNamespacesResponse: Sendable { + /// The namespace names in this page. + public var namespaces: [String] + + /// The pagination token to pass to fetch the next page, or `nil` when there are no more results. + public var nextPageToken: String? +} diff --git a/Sources/Storage/AnalyticsClient.swift b/Sources/Storage/AnalyticsClient.swift new file mode 100644 index 000000000..128f4bc2f --- /dev/null +++ b/Sources/Storage/AnalyticsClient.swift @@ -0,0 +1,205 @@ +// +// AnalyticsClient.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +public import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client for managing Supabase Storage's alpha "analytics buckets" feature +/// (`storage.analytics`) — Iceberg-backed buckets optimized for analytical queries. +/// +/// Obtain an instance via ``SupabaseStorageClient/analytics``: +/// +/// ```swift +/// try await client.storage.analytics.createBucket("events") +/// let buckets = try await client.storage.analytics.listBuckets() +/// ``` +/// +/// - Warning: Analytics 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 analytics buckets +/// +/// - ``createBucket(_:)`` +/// - ``listBuckets(limit:offset:sortColumn:sortOrder:search:)`` +/// - ``deleteBucket(_:)`` +/// +/// ### Managing namespaces and tables +/// +/// - ``from(_:)`` +@_spi(Experimental) +public struct AnalyticsClient: Sendable { + private let api: StorageApi + + init(api: StorageApi) { + self.api = api + } + + /// Creates a new analytics bucket. + /// + /// ```swift + /// let bucket = try await client.storage.analytics.createBucket("events") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: A unique name for the bucket. + /// - Returns: The newly created ``AnalyticBucket``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createBucket(_ name: String) async throws -> AnalyticBucket { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("iceberg/bucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(CreateAnalyticBucketBody(name: name)) + ) + ) + .decoded(decoder: .supabase()) + } + + /// Lists the analytics buckets in the project. + /// + /// ```swift + /// let buckets = try await client.storage.analytics.listBuckets( + /// sortColumn: .createdAt, + /// sortOrder: .descending + /// ) + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - limit: The maximum number of buckets to return. + /// - offset: The number of buckets to skip, for pagination. + /// - sortColumn: The column to sort results by. + /// - sortOrder: The sort direction. + /// - search: A search term to filter bucket names by. + /// - Returns: The matching analytics buckets. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listBuckets( + limit: Int? = nil, + offset: Int? = nil, + sortColumn: AnalyticsBucketSortColumn? = nil, + sortOrder: SortOrder? = nil, + search: String? = nil + ) async throws -> [AnalyticBucket] { + var query: [URLQueryItem] = [] + if let limit { query.append(URLQueryItem(name: "limit", value: String(limit))) } + if let offset { query.append(URLQueryItem(name: "offset", value: String(offset))) } + if let sortColumn { query.append(URLQueryItem(name: "sortColumn", value: sortColumn.rawValue)) } + if let sortOrder { query.append(URLQueryItem(name: "sortOrder", value: sortOrder.rawValue)) } + if let search { query.append(URLQueryItem(name: "search", value: search)) } + + return try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("iceberg/bucket"), + method: .get, + query: query + ) + ) + .decoded(decoder: .supabase()) + } + + /// Deletes an analytics bucket. + /// + /// > Important: A bucket cannot be deleted while it contains namespaces. Drop all namespaces + /// > first. + /// + /// ```swift + /// try await client.storage.analytics.deleteBucket("events") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the bucket to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteBucket(_ name: String) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("iceberg/bucket/\(name)"), + method: .delete + ) + ) + } + + /// Returns a client scoped to the given analytics bucket, for managing its Iceberg namespaces + /// and tables. + /// + /// ```swift + /// let bucket = client.storage.analytics.from("events") + /// try await bucket.createNamespace("default") + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter bucketName: The name of the analytics bucket to operate on. + /// - Returns: An ``AnalyticsBucketClient`` configured for the given bucket. + public func from(_ bucketName: String) -> AnalyticsBucketClient { + AnalyticsBucketClient(bucketName: bucketName, api: api) + } +} + +private struct CreateAnalyticBucketBody: Encodable { + var name: String +} + +/// An analytics (Iceberg-backed) Storage bucket. +/// +/// - Warning: Experimental. See ``AnalyticsClient``. +@_spi(Experimental) +public struct AnalyticBucket: Decodable, Sendable, Hashable { + /// The unique identifier of the bucket, equal to ``name``. + public var id: String + + /// The name of the bucket. + public var name: String + + /// When the bucket was created. + public var createdAt: Date + + /// When the bucket was last updated. + public var updatedAt: Date + + private enum CodingKeys: String, CodingKey { + case id + case name + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// A column to sort analytics buckets by, for +/// ``AnalyticsClient/listBuckets(limit:offset:sortColumn:sortOrder:search:)``. +/// +/// - Warning: Experimental. See ``AnalyticsClient``. +@_spi(Experimental) +public struct AnalyticsBucketSortColumn: RawRepresentable, Hashable, Sendable { + /// The raw string value sent to the API. + public let rawValue: String + + /// Creates an ``AnalyticsBucketSortColumn`` from a raw string value. + public init(rawValue: String) { self.rawValue = rawValue } + + /// Sort by bucket name. + public static let name = AnalyticsBucketSortColumn(rawValue: "name") + + /// Sort by creation time. + public static let createdAt = AnalyticsBucketSortColumn(rawValue: "created_at") + + /// Sort by last-updated time. + public static let updatedAt = AnalyticsBucketSortColumn(rawValue: "updated_at") +} + +extension AnalyticsBucketSortColumn: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self.init(rawValue: value) } +} diff --git a/Sources/Storage/IcebergNamespaceClient.swift b/Sources/Storage/IcebergNamespaceClient.swift new file mode 100644 index 000000000..7db66b64a --- /dev/null +++ b/Sources/Storage/IcebergNamespaceClient.swift @@ -0,0 +1,286 @@ +// +// IcebergNamespaceClient.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client scoped to a single Iceberg namespace, for managing its tables. +/// +/// Obtain an instance via ``AnalyticsBucketClient/namespace(_:)``: +/// +/// ```swift +/// let namespace = client.storage.analytics.from("events").namespace("default") +/// +/// try await namespace.createTable( +/// "clicks", +/// schema: IcebergSchema(fields: [ +/// IcebergStructField(id: 1, name: "id", type: "long", required: true), +/// IcebergStructField(id: 2, name: "url", type: "string", required: false), +/// ]) +/// ) +/// ``` +/// +/// - Warning: Analytics 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 tables +/// +/// - ``createTable(_:schema:location:partitionSpec:properties:writeOrder:stageCreate:)`` +/// - ``getTable(_:)`` +/// - ``listTables(pageToken:pageSize:)`` +/// - ``tableExists(_:)`` +/// - ``deleteTable(_:purge:)`` +@_spi(Experimental) +public struct IcebergNamespaceClient: Sendable { + /// The name of the analytics bucket containing ``namespaceName``. + public let bucketName: String + + /// The name of the namespace this client operates on. + public let namespaceName: String + + private let api: StorageApi + + init(bucketName: String, namespaceName: String, api: StorageApi) { + self.bucketName = bucketName + self.namespaceName = namespaceName + self.api = api + } + + /// Creates a new Iceberg table in this namespace. + /// + /// ```swift + /// try await namespace.createTable( + /// "clicks", + /// schema: IcebergSchema(fields: [ + /// IcebergStructField(id: 1, name: "id", type: "long", required: true), + /// IcebergStructField(id: 2, name: "clicked_at", type: "timestamptz", required: true), + /// ]), + /// partitionSpec: IcebergPartitionSpec(fields: [ + /// IcebergPartitionField(sourceId: 2, name: "clicked_at_day", transform: "day") + /// ]) + /// ) + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - name: The name of the table to create. + /// - schema: The table's schema. + /// - location: An optional URI where the table's metadata and data will live. Defaults to a + /// location managed by the catalog. + /// - partitionSpec: An optional partitioning specification for the table. + /// - properties: Arbitrary key-value properties to set on the table. + /// - writeOrder: An optional sort order applied to writes. + /// - stageCreate: When `true`, initializes table metadata for a create transaction instead of + /// committing immediately. Defaults to `false`. + /// - Returns: The newly created table's metadata. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createTable( + _ name: String, + schema: IcebergSchema, + location: String? = nil, + partitionSpec: IcebergPartitionSpec? = nil, + properties: [String: String]? = nil, + writeOrder: IcebergSortOrder? = nil, + stageCreate: Bool = false + ) async throws -> IcebergLoadTableResult { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(namespaceName)/tables"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + CreateTableBody( + name: name, + location: location, + schema: schema, + spec: partitionSpec, + properties: properties, + stageCreate: stageCreate, + writeOrder: writeOrder + ) + ) + ) + ) + .decoded(decoder: .supabase()) + } + + /// Loads the metadata of an existing table. + /// + /// ```swift + /// let table = try await namespace.getTable("clicks") + /// print(table.metadata.location) + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the table to load. + /// - Returns: The table's metadata. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getTable(_ name: String) async throws -> IcebergLoadTableResult { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(namespaceName)/tables/\(name)"), + method: .get + ) + ) + .decoded(decoder: .supabase()) + } + + /// Lists the tables in this namespace. + /// + /// Results are paginated: pass the ``ListIcebergTablesResponse/nextPageToken`` of a previous + /// response as `pageToken` to fetch the following page. + /// + /// ```swift + /// let page = try await namespace.listTables() + /// for table in page.tables { + /// print(table) + /// } + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - pageToken: The pagination token from a previous response. + /// - pageSize: The maximum number of tables to return in this page. + /// - Returns: A page of table names, plus the token for the next page when more results exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listTables( + pageToken: String? = nil, + pageSize: Int? = nil + ) async throws -> ListIcebergTablesResponse { + var query: [URLQueryItem] = [] + if let pageToken { query.append(URLQueryItem(name: "pageToken", value: pageToken)) } + if let pageSize { query.append(URLQueryItem(name: "pageSize", value: String(pageSize))) } + + let response: ListTablesResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(namespaceName)/tables"), + method: .get, + query: query + ) + ) + .decoded(decoder: .supabase()) + return ListIcebergTablesResponse( + tables: response.identifiers.map(\.name), + nextPageToken: response.nextPageToken + ) + } + + /// Checks whether a table exists in this namespace. + /// + /// ```swift + /// if try await namespace.tableExists("clicks") { + /// // ... + /// } + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameter name: The name of the table to check. + /// - Returns: `true` if the table exists, `false` otherwise. + /// - Throws: ``StorageError`` for any failure other than the table not existing. + public func tableExists(_ name: String) async throws -> Bool { + do { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(namespaceName)/tables/\(name)"), + method: .head + ) + ) + return true + } catch let error as StorageError { + if let statusCode = error.statusCode.flatMap(Int.init), [400, 404].contains(statusCode) { + return false + } + throw error + } + } + + /// Deletes a table. + /// + /// ```swift + /// try await namespace.deleteTable("clicks", purge: true) + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + /// + /// - Parameters: + /// - name: The name of the table to delete. + /// - purge: When `true`, permanently deletes the table's underlying data. When `false` + /// (the default), the table is dropped from the catalog but its data is retained. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteTable(_ name: String, purge: Bool = false) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent( + "iceberg/v1/\(bucketName)/namespaces/\(namespaceName)/tables/\(name)"), + method: .delete, + query: [URLQueryItem(name: "purgeRequested", value: purge ? "true" : "false")] + ) + ) + } +} + +private struct CreateTableBody: Encodable { + var name: String + var location: String? + var schema: IcebergSchema + var spec: IcebergPartitionSpec? + var properties: [String: String]? + var stageCreate: Bool + var writeOrder: IcebergSortOrder? + + private enum CodingKeys: String, CodingKey { + case name + case location + case schema + case spec + case properties + case stageCreate = "stage-create" + case writeOrder = "write-order" + } +} + +private struct ListTablesResponseBody: Decodable { + var identifiers: [IcebergTableIdentifierBody] + var nextPageToken: String? + + private enum CodingKeys: String, CodingKey { + case identifiers + case nextPageToken = "next-page-token" + } +} + +private struct IcebergTableIdentifierBody: Decodable { + var name: String + var namespace: [String] +} + +/// A page of table names, as returned by +/// ``IcebergNamespaceClient/listTables(pageToken:pageSize:)``. +/// +/// - Warning: Experimental. See ``AnalyticsClient``. +@_spi(Experimental) +public struct ListIcebergTablesResponse: Sendable { + /// The table names in this page. + public var tables: [String] + + /// The pagination token to pass to fetch the next page, or `nil` when there are no more results. + public var nextPageToken: String? +} diff --git a/Sources/Storage/IcebergTypes.swift b/Sources/Storage/IcebergTypes.swift new file mode 100644 index 000000000..10efddecc --- /dev/null +++ b/Sources/Storage/IcebergTypes.swift @@ -0,0 +1,471 @@ +// +// IcebergTypes.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +public import Foundation + +/// An Apache Iceberg primitive or nested field type. +/// +/// Use a string literal for primitive types (`"long"`, `"string"`, `"timestamp"`, etc.) or one of +/// the nested cases for `struct`, `list`, and `map` columns. +/// +/// ```swift +/// IcebergStructField(id: 1, name: "id", type: "long", required: true) +/// IcebergStructField( +/// id: 2, +/// name: "tags", +/// type: .listType(elementId: 3, element: "string", elementRequired: false), +/// required: false +/// ) +/// ``` +/// +/// - Warning: Analytics 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`. +@_spi(Experimental) +public indirect enum IcebergType: Sendable, Hashable { + /// A primitive type name, e.g. `"boolean"`, `"integer"`, `"long"`, `"float"`, `"double"`, + /// `"date"`, `"time"`, `"timestamp"`, `"timestamptz"`, `"string"`, `"uuid"`, `"fixed"`, or + /// `"binary"`. + case primitive(String) + + /// A nested struct column composed of the given fields. + case structType(fields: [IcebergStructField]) + + /// A list column. + case listType(elementId: Int, element: IcebergType, elementRequired: Bool) + + /// A map column. + case mapType(keyId: Int, key: IcebergType, valueId: Int, value: IcebergType, valueRequired: Bool) +} + +extension IcebergType: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .primitive(value) + } +} + +extension IcebergType: Codable { + private enum CodingKeys: String, CodingKey { + case type + case fields + case elementId = "element-id" + case element + case elementRequired = "element-required" + case keyId = "key-id" + case key + case valueId = "value-id" + case value + case valueRequired = "value-required" + } + + public init(from decoder: any Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let primitive = try? container.decode(String.self) + { + self = .primitive(primitive) + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "struct": + self = .structType(fields: try container.decode([IcebergStructField].self, forKey: .fields)) + case "list": + self = .listType( + elementId: try container.decode(Int.self, forKey: .elementId), + element: try container.decode(IcebergType.self, forKey: .element), + elementRequired: try container.decode(Bool.self, forKey: .elementRequired) + ) + case "map": + self = .mapType( + keyId: try container.decode(Int.self, forKey: .keyId), + key: try container.decode(IcebergType.self, forKey: .key), + valueId: try container.decode(Int.self, forKey: .valueId), + value: try container.decode(IcebergType.self, forKey: .value), + valueRequired: try container.decode(Bool.self, forKey: .valueRequired) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown Iceberg field type: \(type)") + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case .primitive(let name): + var container = encoder.singleValueContainer() + try container.encode(name) + case .structType(let fields): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("struct", forKey: .type) + try container.encode(fields, forKey: .fields) + case .listType(let elementId, let element, let elementRequired): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("list", forKey: .type) + try container.encode(elementId, forKey: .elementId) + try container.encode(element, forKey: .element) + try container.encode(elementRequired, forKey: .elementRequired) + case .mapType(let keyId, let key, let valueId, let value, let valueRequired): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("map", forKey: .type) + try container.encode(keyId, forKey: .keyId) + try container.encode(key, forKey: .key) + try container.encode(valueId, forKey: .valueId) + try container.encode(value, forKey: .value) + try container.encode(valueRequired, forKey: .valueRequired) + } + } +} + +/// A single field within an ``IcebergSchema`` or nested ``IcebergType/structType(fields:)``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergStructField: Codable, Sendable, Hashable { + /// The field's unique ID within the table. + public var id: Int + + /// The field's name. + public var name: String + + /// The field's type. + public var type: IcebergType + + /// Whether the field is required (`NOT NULL`). + public var required: Bool + + /// An optional human-readable description of the field. + public var doc: String? + + /// Creates an ``IcebergStructField``. + public init(id: Int, name: String, type: IcebergType, required: Bool, doc: String? = nil) { + self.id = id + self.name = name + self.type = type + self.required = required + self.doc = doc + } +} + +/// An Iceberg table schema: a `struct` of top-level fields. +/// +/// ```swift +/// IcebergSchema(fields: [ +/// IcebergStructField(id: 1, name: "id", type: "long", required: true), +/// IcebergStructField(id: 2, name: "name", type: "string", required: false), +/// ]) +/// ``` +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergSchema: Sendable, Hashable { + /// The schema's top-level fields. + public var fields: [IcebergStructField] + + /// The schema's identifier, assigned by the catalog. + public var schemaId: Int? + + /// IDs of fields that make up the table's identifier (primary key). + public var identifierFieldIds: [Int]? + + /// Creates an ``IcebergSchema``. + public init(fields: [IcebergStructField], schemaId: Int? = nil, identifierFieldIds: [Int]? = nil) + { + self.fields = fields + self.schemaId = schemaId + self.identifierFieldIds = identifierFieldIds + } +} + +extension IcebergSchema: Codable { + private enum CodingKeys: String, CodingKey { + case type + case fields + case schemaId = "schema-id" + case identifierFieldIds = "identifier-field-ids" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fields = try container.decode([IcebergStructField].self, forKey: .fields) + schemaId = try container.decodeIfPresent(Int.self, forKey: .schemaId) + identifierFieldIds = try container.decodeIfPresent([Int].self, forKey: .identifierFieldIds) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("struct", forKey: .type) + try container.encode(fields, forKey: .fields) + try container.encodeIfPresent(schemaId, forKey: .schemaId) + try container.encodeIfPresent(identifierFieldIds, forKey: .identifierFieldIds) + } +} + +/// A single partition field within an ``IcebergPartitionSpec``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergPartitionField: Codable, Sendable, Hashable { + /// The ID of the source field (from the table's schema) this partition field derives from. + public var sourceId: Int + + /// The partition field's own ID, assigned by the catalog. + public var fieldId: Int? + + /// The partition field's name. + public var name: String + + /// The transform applied to the source field, e.g. `"identity"`, `"bucket[16]"`, `"day"`. + public var transform: String + + private enum CodingKeys: String, CodingKey { + case sourceId = "source-id" + case fieldId = "field-id" + case name + case transform + } + + /// Creates an ``IcebergPartitionField``. + public init(sourceId: Int, name: String, transform: String, fieldId: Int? = nil) { + self.sourceId = sourceId + self.name = name + self.transform = transform + self.fieldId = fieldId + } +} + +/// An Iceberg table's partitioning specification. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergPartitionSpec: Codable, Sendable, Hashable { + /// The partition fields, applied in order. + public var fields: [IcebergPartitionField] + + /// The spec's identifier, assigned by the catalog. + public var specId: Int? + + private enum CodingKeys: String, CodingKey { + case fields + case specId = "spec-id" + } + + /// Creates an ``IcebergPartitionSpec``. + public init(fields: [IcebergPartitionField], specId: Int? = nil) { + self.fields = fields + self.specId = specId + } +} + +/// Sort direction for an ``IcebergSortField``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergSortDirection: RawRepresentable, Hashable, Sendable { + /// The raw string value sent to the API. + public let rawValue: String + + /// Creates an ``IcebergSortDirection`` from a raw string value. + public init(rawValue: String) { self.rawValue = rawValue } + + /// Ascending order. + public static let ascending = IcebergSortDirection(rawValue: "asc") + + /// Descending order. + public static let descending = IcebergSortDirection(rawValue: "desc") +} + +extension IcebergSortDirection: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self.init(rawValue: value) } +} + +extension IcebergSortDirection: Codable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } +} + +/// Null ordering for an ``IcebergSortField``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergNullOrder: RawRepresentable, Hashable, Sendable { + /// The raw string value sent to the API. + public let rawValue: String + + /// Creates an ``IcebergNullOrder`` from a raw string value. + public init(rawValue: String) { self.rawValue = rawValue } + + /// Nulls sort before non-null values. + public static let first = IcebergNullOrder(rawValue: "nulls-first") + + /// Nulls sort after non-null values. + public static let last = IcebergNullOrder(rawValue: "nulls-last") +} + +extension IcebergNullOrder: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self.init(rawValue: value) } +} + +extension IcebergNullOrder: Codable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } +} + +/// A single field within an ``IcebergSortOrder``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergSortField: Codable, Sendable, Hashable { + /// The ID of the source field (from the table's schema) this sort field derives from. + public var sourceId: Int + + /// The transform applied to the source field before sorting, e.g. `"identity"`. + public var transform: String + + /// The sort direction. + public var direction: IcebergSortDirection + + /// How `null` values are ordered. + public var nullOrder: IcebergNullOrder + + private enum CodingKeys: String, CodingKey { + case sourceId = "source-id" + case transform + case direction + case nullOrder = "null-order" + } + + /// Creates an ``IcebergSortField``. + public init( + sourceId: Int, transform: String, direction: IcebergSortDirection, nullOrder: IcebergNullOrder + ) { + self.sourceId = sourceId + self.transform = transform + self.direction = direction + self.nullOrder = nullOrder + } +} + +/// An Iceberg table's write-order specification, used to sort data on write. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergSortOrder: Codable, Sendable, Hashable { + /// The sort fields, applied in order. + public var fields: [IcebergSortField] + + /// The order's identifier, assigned by the catalog. + public var orderId: Int? + + private enum CodingKeys: String, CodingKey { + case fields + case orderId = "order-id" + } + + /// Creates an ``IcebergSortOrder``. + public init(fields: [IcebergSortField], orderId: Int? = nil) { + self.fields = fields + self.orderId = orderId + } +} + +/// An Iceberg table's metadata, as returned by ``IcebergNamespaceClient``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergTableMetadata: Decodable, Sendable, Hashable { + /// The table format version (1 or 2). + public var formatVersion: Int + + /// A UUID that uniquely identifies the table. + public var tableUUID: String + + /// The base location of the table's data and metadata files. + public var location: String? + + /// When the metadata was last updated. + public var lastUpdatedAt: TimeInterval? + + /// Arbitrary key-value table properties. + public var properties: [String: String]? + + /// The schema history for the table. + public var schemas: [IcebergSchema]? + + /// The ID of the current schema in ``schemas``. + public var currentSchemaId: Int? + + /// The ID of the table's current snapshot, if any. + public var currentSnapshotId: Int? + + /// All known partition specs for the table. + public var partitionSpecs: [IcebergPartitionSpec]? + + /// The ID of the default partition spec in ``partitionSpecs``. + public var defaultSpecId: Int? + + private enum CodingKeys: String, CodingKey { + case formatVersion = "format-version" + case tableUUID = "table-uuid" + case location + case lastUpdatedMs = "last-updated-ms" + case properties + case schemas + case currentSchemaId = "current-schema-id" + case currentSnapshotId = "current-snapshot-id" + case partitionSpecs = "partition-specs" + case defaultSpecId = "default-spec-id" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + formatVersion = try container.decode(Int.self, forKey: .formatVersion) + tableUUID = try container.decode(String.self, forKey: .tableUUID) + location = try container.decodeIfPresent(String.self, forKey: .location) + let lastUpdatedMs = try container.decodeIfPresent(Int.self, forKey: .lastUpdatedMs) + lastUpdatedAt = lastUpdatedMs.map { TimeInterval($0) / 1000 } + properties = try container.decodeIfPresent([String: String].self, forKey: .properties) + schemas = try container.decodeIfPresent([IcebergSchema].self, forKey: .schemas) + currentSchemaId = try container.decodeIfPresent(Int.self, forKey: .currentSchemaId) + currentSnapshotId = try container.decodeIfPresent(Int.self, forKey: .currentSnapshotId) + partitionSpecs = try container.decodeIfPresent( + [IcebergPartitionSpec].self, forKey: .partitionSpecs) + defaultSpecId = try container.decodeIfPresent(Int.self, forKey: .defaultSpecId) + } +} + +/// The result of creating or loading an Iceberg table, as returned by ``IcebergNamespaceClient``. +/// +/// - Warning: Experimental. See ``IcebergType``. +@_spi(Experimental) +public struct IcebergLoadTableResult: Decodable, Sendable, Hashable { + /// The location of the table's metadata file. + public var metadataLocation: String + + /// The table's metadata. + public var metadata: IcebergTableMetadata + + private enum CodingKeys: String, CodingKey { + case metadataLocation = "metadata-location" + case metadata + } +} diff --git a/Sources/Storage/StorageError.swift b/Sources/Storage/StorageError.swift index 0a71bfaa1..01180ae07 100644 --- a/Sources/Storage/StorageError.swift +++ b/Sources/Storage/StorageError.swift @@ -13,7 +13,7 @@ public import Foundation /// print(error.statusCode ?? "unknown", error.message) /// } /// ``` -public struct StorageError: Error, Decodable, Sendable { +public struct StorageError: Error, Sendable { /// The HTTP status code returned by the API, represented as a string (e.g. `"404"`). public var statusCode: String? @@ -36,6 +36,41 @@ public struct StorageError: Error, Decodable, Sendable { } } +extension StorageError: Decodable { + /// The flat `{ statusCode, message, error }` shape used by most Storage endpoints. + private struct FlatBody: Decodable { + var statusCode: String? + var message: String + var error: String? + } + + /// The `{ error: { message, type, code } }` shape used by the analytics/Iceberg endpoints, + /// which run through a different error formatter on the backend. + private struct NestedBody: Decodable { + struct Inner: Decodable { + var message: String + var type: String? + var code: Int? + } + + var error: Inner + } + + public init(from decoder: any Decoder) throws { + if let flat = try? FlatBody(from: decoder) { + self.init(statusCode: flat.statusCode, message: flat.message, error: flat.error) + return + } + + let nested = try NestedBody(from: decoder) + self.init( + statusCode: nested.error.code.map(String.init), + message: nested.error.message, + error: nested.error.type + ) + } +} + extension StorageError: LocalizedError { /// A localized description of the error, equal to ``message``. public var errorDescription: String? { diff --git a/Sources/Storage/StorageVectorsClient.swift b/Sources/Storage/StorageVectorsClient.swift new file mode 100644 index 000000000..fe398521e --- /dev/null +++ b/Sources/Storage/StorageVectorsClient.swift @@ -0,0 +1,210 @@ +// +// StorageVectorsClient.swift +// Storage +// +// Created by Guilherme Souza on 27/07/26. +// + +public 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(_:)`` +/// +/// ### Managing indexes and vector data +/// +/// - ``from(_:)`` +@_spi(Experimental) +public struct StorageVectorsClient: Sendable { + private let api: StorageApi + + init(api: StorageApi) { + self.api = api + } + + /// Returns a client scoped to the given vector bucket, for managing its indexes and vector data. + /// + /// ```swift + /// let bucket = client.storage.vectors.from("documents") + /// try await bucket.createIndex("embeddings", dimension: 1536, distanceMetric: .cosine) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter vectorBucketName: The name of the vector bucket to operate on. + /// - Returns: A ``VectorBucketClient`` configured for the given bucket. + public func from(_ vectorBucketName: String) -> VectorBucketClient { + VectorBucketClient(vectorBucketName: vectorBucketName, api: api) + } + /// 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 api.execute( + HTTPRequest( + url: api.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 api.execute( + HTTPRequest( + url: api.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 api.execute( + HTTPRequest( + url: api.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 api.execute( + HTTPRequest( + url: api.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: TimeInterval? +} + +/// 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..9723af8ca 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -103,6 +103,8 @@ public struct StorageClientConfiguration: Sendable { /// ### Accessing buckets /// /// - ``from(_:)`` +/// - ``vectors`` +/// - ``analytics`` /// /// ### Bucket management /// @@ -123,4 +125,30 @@ 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(api: self) + } + + /// A client for managing analytics (Iceberg-backed) buckets. + /// + /// ```swift + /// try await client.storage.analytics.createBucket("events") + /// let buckets = try await client.storage.analytics.listBuckets() + /// ``` + /// + /// - Warning: Experimental. See ``AnalyticsClient``. + @_spi(Experimental) + public var analytics: AnalyticsClient { + AnalyticsClient(api: self) + } } diff --git a/Sources/Storage/VectorBucketClient.swift b/Sources/Storage/VectorBucketClient.swift new file mode 100644 index 000000000..4d7f0564f --- /dev/null +++ b/Sources/Storage/VectorBucketClient.swift @@ -0,0 +1,408 @@ +// +// VectorBucketClient.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +public import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client scoped to a single vector bucket, for managing its indexes and vector data. +/// +/// Obtain an instance via ``StorageVectorsClient/from(_:)``: +/// +/// ```swift +/// let bucket = client.storage.vectors.from("documents") +/// try await bucket.createIndex("embeddings", dimension: 1536, distanceMetric: .cosine) +/// let index = bucket.index("embeddings") +/// ``` +/// +/// - 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 indexes +/// +/// - ``createIndex(_:dimension:distanceMetric:dataType:metadataConfiguration:)`` +/// - ``getIndex(_:)`` +/// - ``listIndexes(prefix:maxResults:nextToken:)`` +/// - ``deleteIndex(_:)`` +/// +/// ### Accessing vector data +/// +/// - ``index(_:)`` +@_spi(Experimental) +public struct VectorBucketClient: Sendable { + /// The name of the vector bucket this client operates on. + public let vectorBucketName: String + + private let api: StorageApi + + init(vectorBucketName: String, api: StorageApi) { + self.vectorBucketName = vectorBucketName + self.api = api + } + + /// Creates a new vector index within this bucket. + /// + /// ```swift + /// try await bucket.createIndex( + /// "embeddings", + /// dimension: 1536, + /// distanceMetric: .cosine, + /// metadataConfiguration: VectorIndexMetadataConfiguration( + /// nonFilterableMetadataKeys: ["raw_text"] + /// ) + /// ) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - indexName: A unique name for the index within this bucket. 3-63 characters: lowercase + /// letters, numbers, hyphens, and dots, starting and ending with a letter or number. + /// - dimension: The dimensionality of vectors stored in this index (e.g. `1536`). + /// - distanceMetric: The similarity metric used when querying this index. + /// - dataType: The data type of vector components. Defaults to ``VectorDataType/float32``, + /// currently the only supported value. + /// - metadataConfiguration: Configuration for which metadata keys are excluded from filtering. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createIndex( + _ indexName: String, + dimension: Int, + distanceMetric: VectorDistanceMetric, + dataType: VectorDataType = .float32, + metadataConfiguration: VectorIndexMetadataConfiguration? = nil + ) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/CreateIndex"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + CreateIndexBody( + vectorBucketName: vectorBucketName, + indexName: indexName, + dataType: dataType, + dimension: dimension, + distanceMetric: distanceMetric, + metadataConfiguration: metadataConfiguration + ) + ) + ) + ) + } + + /// Retrieves metadata for an existing vector index in this bucket. + /// + /// ```swift + /// let index = try await bucket.getIndex("embeddings") + /// print(index.dimension) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter indexName: The name of the index to retrieve. + /// - Returns: The matching ``VectorIndex``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getIndex(_ indexName: String) async throws -> VectorIndex { + let response: GetIndexResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/GetIndex"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + VectorBucketIndexNameBody(vectorBucketName: vectorBucketName, indexName: indexName) + ) + ) + ) + .decoded(decoder: .supabase()) + return response.index + } + + /// Lists the vector indexes in this bucket, optionally filtered by name prefix. + /// + /// Results are paginated: pass the ``ListVectorIndexesResponse/nextToken`` of a previous + /// response as `nextToken` to fetch the following page. + /// + /// ```swift + /// let page = try await bucket.listIndexes(prefix: "embeddings-") + /// for index in page.indexes { + /// print(index.indexName) + /// } + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - prefix: Returns only indexes whose name starts with this prefix. Pass `nil` for all + /// indexes. + /// - maxResults: The maximum number of indexes to return in this page. + /// - nextToken: The pagination token from a previous response. + /// - Returns: A page of indexes, plus the token for the next page when more results exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listIndexes( + prefix: String? = nil, + maxResults: Int? = nil, + nextToken: String? = nil + ) async throws -> ListVectorIndexesResponse { + let response: ListIndexesResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/ListIndexes"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + ListIndexesBody( + vectorBucketName: vectorBucketName, + prefix: prefix, + maxResults: maxResults, + nextToken: nextToken + ) + ) + ) + ) + .decoded(decoder: .supabase()) + return ListVectorIndexesResponse(indexes: response.indexes, nextToken: response.nextToken) + } + + /// Deletes a vector index and all of its vector data. + /// + /// ```swift + /// try await bucket.deleteIndex("embeddings") + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter indexName: The name of the index to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteIndex(_ indexName: String) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/DeleteIndex"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + VectorBucketIndexNameBody(vectorBucketName: vectorBucketName, indexName: indexName) + ) + ) + ) + } + + /// Returns a client scoped to the given index, for reading and writing vector data. + /// + /// ```swift + /// let index = client.storage.vectors.from("documents").index("embeddings") + /// try await index.putVectors([ + /// VectorEntry(key: "doc-1", data: VectorData(float32: [0.1, 0.2, 0.3])) + /// ]) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter indexName: The name of the index. + /// - Returns: A ``VectorIndexClient`` configured for the given index. + public func index(_ indexName: String) -> VectorIndexClient { + VectorIndexClient( + vectorBucketName: vectorBucketName, + indexName: indexName, + api: api + ) + } +} + +private struct VectorBucketIndexNameBody: Encodable { + var vectorBucketName: String + var indexName: String +} + +private struct CreateIndexBody: Encodable { + var vectorBucketName: String + var indexName: String + var dataType: VectorDataType + var dimension: Int + var distanceMetric: VectorDistanceMetric + var metadataConfiguration: VectorIndexMetadataConfiguration? +} + +private struct ListIndexesBody: Encodable { + var vectorBucketName: String + var prefix: String? + var maxResults: Int? + var nextToken: String? +} + +private struct GetIndexResponseBody: Decodable { + var index: VectorIndex +} + +private struct ListIndexesResponseBody: Decodable { + var indexes: [VectorIndexSummary] + var nextToken: String? +} + +/// The data type of the components stored in a vector index. +/// +/// ```swift +/// try await bucket.createIndex("embeddings", dimension: 1536, distanceMetric: .cosine, dataType: .float32) +/// ``` +/// +/// ## Topics +/// +/// ### Predefined data types +/// +/// - ``float32`` +@_spi(Experimental) +public struct VectorDataType: RawRepresentable, Hashable, Sendable { + /// The raw string value sent to the API. + public let rawValue: String + + /// Creates a ``VectorDataType`` from a raw string value. + /// + /// - Parameter rawValue: The data type string understood by the Storage vectors API. + public init(rawValue: String) { self.rawValue = rawValue } + + /// 32-bit floating point vector components. Currently the only supported data type. + public static let float32 = VectorDataType(rawValue: "float32") +} + +extension VectorDataType: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self.init(rawValue: value) } +} + +extension VectorDataType: Codable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } +} + +/// The similarity metric used to rank results when querying a vector index. +/// +/// ```swift +/// try await bucket.createIndex("embeddings", dimension: 1536, distanceMetric: .cosine) +/// ``` +/// +/// ## Topics +/// +/// ### Predefined metrics +/// +/// - ``cosine`` +/// - ``euclidean`` +/// - ``dotProduct`` +@_spi(Experimental) +public struct VectorDistanceMetric: RawRepresentable, Hashable, Sendable { + /// The raw string value sent to the API. + public let rawValue: String + + /// Creates a ``VectorDistanceMetric`` from a raw string value. + /// + /// - Parameter rawValue: The distance metric string understood by the Storage vectors API. + public init(rawValue: String) { self.rawValue = rawValue } + + /// Cosine similarity. + public static let cosine = VectorDistanceMetric(rawValue: "cosine") + + /// Euclidean (L2) distance. + public static let euclidean = VectorDistanceMetric(rawValue: "euclidean") + + /// Dot product similarity. + /// + /// - Note: Support depends on the underlying vector store backend. + public static let dotProduct = VectorDistanceMetric(rawValue: "dotproduct") +} + +extension VectorDistanceMetric: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { self.init(rawValue: value) } +} + +extension VectorDistanceMetric: Codable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } +} + +/// Configuration for which metadata keys are excluded from filtering on a vector index. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorIndexMetadataConfiguration: Codable, Sendable, Hashable { + /// Metadata keys that can be stored but not used in ``VectorIndexClient`` query filters. + public var nonFilterableMetadataKeys: [String] + + /// Creates a ``VectorIndexMetadataConfiguration``. + /// + /// - Parameter nonFilterableMetadataKeys: Metadata keys to exclude from filtering. + public init(nonFilterableMetadataKeys: [String]) { + self.nonFilterableMetadataKeys = nonFilterableMetadataKeys + } +} + +/// A vector index, as returned by ``VectorBucketClient``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorIndex: Codable, Sendable, Hashable { + /// The name of the index. + public var indexName: String + + /// The name of the vector bucket this index belongs to. + public var vectorBucketName: String + + /// The data type of the vector components stored in this index. + public var dataType: VectorDataType + + /// The dimensionality of vectors stored in this index. + public var dimension: Int + + /// The similarity metric used when querying this index. + public var distanceMetric: VectorDistanceMetric + + /// Configuration for which metadata keys are excluded from filtering, if any. + public var metadataConfiguration: VectorIndexMetadataConfiguration? + + /// UNIX timestamp (seconds) of when the index was created, if known. + public var creationTime: TimeInterval? +} + +/// A summary of a vector index, as returned by +/// ``VectorBucketClient/listIndexes(prefix:maxResults:nextToken:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorIndexSummary: Codable, Sendable, Hashable { + /// The name of the index. + public var indexName: String + + /// The name of the vector bucket this index belongs to. + public var vectorBucketName: String + + /// UNIX timestamp (seconds) of when the index was created, if known. + public var creationTime: TimeInterval? +} + +/// A page of vector indexes, as returned by +/// ``VectorBucketClient/listIndexes(prefix:maxResults:nextToken:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct ListVectorIndexesResponse: Sendable { + /// The indexes in this page. + public var indexes: [VectorIndexSummary] + + /// 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/VectorIndexClient.swift b/Sources/Storage/VectorIndexClient.swift new file mode 100644 index 000000000..753b1d473 --- /dev/null +++ b/Sources/Storage/VectorIndexClient.swift @@ -0,0 +1,435 @@ +// +// VectorIndexClient.swift +// Storage +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client scoped to a single vector index, for reading and writing its vector data. +/// +/// Obtain an instance via ``VectorBucketClient/index(_:)``: +/// +/// ```swift +/// let index = client.storage.vectors.from("documents").index("embeddings") +/// +/// try await index.putVectors([ +/// VectorEntry(key: "doc-1", data: VectorData(float32: [0.1, 0.2, 0.3]), metadata: ["title": "Intro"]) +/// ]) +/// +/// let results = try await index.queryVectors( +/// VectorData(float32: [0.1, 0.2, 0.3]), +/// topK: 5, +/// returnMetadata: true +/// ) +/// ``` +/// +/// - 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 +/// +/// ### Writing vectors +/// +/// - ``putVectors(_:)`` +/// - ``deleteVectors(keys:)`` +/// +/// ### Reading vectors +/// +/// - ``getVectors(keys:returnData:returnMetadata:)`` +/// - ``listVectors(maxResults:nextToken:returnData:returnMetadata:segment:)`` +/// - ``queryVectors(_:topK:filter:returnDistance:returnMetadata:)`` +@_spi(Experimental) +public struct VectorIndexClient: Sendable { + /// The name of the vector bucket containing ``indexName``. + public let vectorBucketName: String + + /// The name of the index this client operates on. + public let indexName: String + + private let api: StorageApi + + init(vectorBucketName: String, indexName: String, api: StorageApi) { + self.vectorBucketName = vectorBucketName + self.indexName = indexName + self.api = api + } + + /// Inserts or updates vectors in this index, in batches of 1-500 entries. + /// + /// Entries are upserted by ``VectorEntry/key``: an existing vector with the same key is + /// replaced. + /// + /// ```swift + /// try await index.putVectors([ + /// VectorEntry( + /// key: "doc-1", + /// data: VectorData(float32: [0.1, 0.2, 0.3]), + /// metadata: ["title": "Introduction", "page": 1] + /// ) + /// ]) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter vectors: The vectors to insert or update. + /// - Throws: ``StorageError`` when the API rejects the request. + public func putVectors(_ vectors: [VectorEntry]) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/PutVectors"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + PutVectorsBody( + vectorBucketName: vectorBucketName, + indexName: indexName, + vectors: vectors + ) + ) + ) + ) + } + + /// Retrieves vectors by key, in batches of up to 100 keys. + /// + /// ```swift + /// let vectors = try await index.getVectors(keys: ["doc-1", "doc-2"], returnMetadata: true) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - keys: The keys of the vectors to retrieve. + /// - returnData: Whether to include vector embedding data in the response. Defaults to `false`. + /// - returnMetadata: Whether to include metadata in the response. Defaults to `false`. + /// - Returns: The matching vectors, in no particular order. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getVectors( + keys: [String], + returnData: Bool = false, + returnMetadata: Bool = false + ) async throws -> [VectorMatch] { + let response: GetVectorsResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/GetVectors"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + GetVectorsBody( + vectorBucketName: vectorBucketName, + indexName: indexName, + keys: keys, + returnData: returnData, + returnMetadata: returnMetadata + ) + ) + ) + ) + .decoded(decoder: .supabase()) + return response.vectors + } + + /// Lists vectors in this index with pagination. + /// + /// Results are paginated: pass the ``ListVectorsResponse/nextToken`` of a previous response as + /// `nextToken` to fetch the following page. Use `segment` to scan the index in parallel across + /// multiple workers. + /// + /// ```swift + /// let page = try await index.listVectors(maxResults: 500, returnMetadata: true) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - maxResults: The maximum number of vectors to return in this page (up to 1000). + /// - nextToken: The pagination token from a previous response. + /// - returnData: Whether to include vector embedding data in the response. Defaults to `false`. + /// - returnMetadata: Whether to include metadata in the response. Defaults to `false`. + /// - segment: Scans only the given slice of the index, for parallel listing. + /// - Returns: A page of vectors, plus the token for the next page when more results exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listVectors( + maxResults: Int? = nil, + nextToken: String? = nil, + returnData: Bool = false, + returnMetadata: Bool = false, + segment: VectorListSegment? = nil + ) async throws -> ListVectorsResponse { + let response: ListVectorsResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/ListVectors"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + ListVectorsBody( + vectorBucketName: vectorBucketName, + indexName: indexName, + maxResults: maxResults, + nextToken: nextToken, + returnData: returnData, + returnMetadata: returnMetadata, + segmentCount: segment?.count, + segmentIndex: segment?.index + ) + ) + ) + ) + .decoded(decoder: .supabase()) + return ListVectorsResponse(vectors: response.vectors, nextToken: response.nextToken) + } + + /// Queries for the vectors most similar to `queryVector` using approximate nearest neighbor + /// search. + /// + /// ```swift + /// let results = try await index.queryVectors( + /// VectorData(float32: [0.1, 0.2, 0.3]), + /// topK: 5, + /// filter: ["category": "technical"], + /// returnDistance: true, + /// returnMetadata: true + /// ) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - queryVector: The vector to find similar vectors for. Must match the index's dimension. + /// - topK: The number of nearest neighbors to return (up to 100). + /// - filter: A metadata filter expression. Supports field operators (`$eq`, `$ne`, `$gt`, + /// `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`) and logical operators (`$and`, `$or`) + /// with arbitrary nesting, e.g. `["category": "technical"]` or + /// `["price": ["$gte": 10]]`. + /// - returnDistance: Whether to include the similarity distance for each match. Defaults to + /// `false`. + /// - returnMetadata: Whether to include metadata in the response. Defaults to `false`. + /// - Returns: The nearest vectors, ordered by ascending distance. + /// - Throws: ``StorageError`` when the API rejects the request. + public func queryVectors( + _ queryVector: VectorData, + topK: Int, + filter: [String: AnyJSON]? = nil, + returnDistance: Bool = false, + returnMetadata: Bool = false + ) async throws -> QueryVectorsResponse { + let response: QueryVectorsResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/QueryVectors"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + QueryVectorsBody( + vectorBucketName: vectorBucketName, + indexName: indexName, + queryVector: queryVector, + topK: topK, + filter: filter, + returnDistance: returnDistance, + returnMetadata: returnMetadata + ) + ) + ) + ) + .decoded(decoder: .supabase()) + return QueryVectorsResponse(vectors: response.vectors, distanceMetric: response.distanceMetric) + } + + /// Deletes vectors by key, in batches of up to 500 keys. + /// + /// ```swift + /// try await index.deleteVectors(keys: ["doc-1", "doc-2"]) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter keys: The keys of the vectors to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteVectors(keys: [String]) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/DeleteVectors"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + DeleteVectorsBody(vectorBucketName: vectorBucketName, indexName: indexName, keys: keys) + ) + ) + ) + } +} + +private struct PutVectorsBody: Encodable { + var vectorBucketName: String + var indexName: String + var vectors: [VectorEntry] +} + +private struct GetVectorsBody: Encodable { + var vectorBucketName: String + var indexName: String + var keys: [String] + var returnData: Bool + var returnMetadata: Bool +} + +private struct GetVectorsResponseBody: Decodable { + var vectors: [VectorMatch] +} + +private struct ListVectorsBody: Encodable { + var vectorBucketName: String + var indexName: String + var maxResults: Int? + var nextToken: String? + var returnData: Bool + var returnMetadata: Bool + var segmentCount: Int? + var segmentIndex: Int? +} + +private struct ListVectorsResponseBody: Decodable { + var vectors: [VectorMatch] + var nextToken: String? +} + +private struct QueryVectorsBody: Encodable { + var vectorBucketName: String + var indexName: String + var queryVector: VectorData + var topK: Int + var filter: [String: AnyJSON]? + var returnDistance: Bool + var returnMetadata: Bool +} + +private struct QueryVectorsResponseBody: Decodable { + var vectors: [VectorMatch] + var distanceMetric: VectorDistanceMetric? +} + +private struct DeleteVectorsBody: Encodable { + var vectorBucketName: String + var indexName: String + var keys: [String] +} + +/// A vector embedding, currently always 32-bit floating point components. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorData: Codable, Sendable, Hashable { + /// The vector's components. + public var float32: [Float] + + /// Creates a ``VectorData`` value. + /// + /// - Parameter float32: The vector's components. Must match the index's configured dimension. + public init(float32: [Float]) { + self.float32 = float32 + } +} + +/// A single vector to insert or update via ``VectorIndexClient/putVectors(_:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorEntry: Codable, Sendable, Hashable { + /// A unique identifier for the vector within its index. + public var key: String + + /// The vector's embedding data. + public var data: VectorData + + /// Arbitrary metadata to store alongside the vector. + public var metadata: [String: AnyJSON]? + + /// Creates a ``VectorEntry``. + /// + /// - Parameters: + /// - key: A unique identifier for the vector within its index. + /// - data: The vector's embedding data. + /// - metadata: Arbitrary metadata to store alongside the vector. + public init(key: String, data: VectorData, metadata: [String: AnyJSON]? = nil) { + self.key = key + self.data = data + self.metadata = metadata + } +} + +/// A vector returned from ``VectorIndexClient/getVectors(keys:returnData:returnMetadata:)``, +/// ``VectorIndexClient/listVectors(maxResults:nextToken:returnData:returnMetadata:segment:)``, or +/// ``VectorIndexClient/queryVectors(_:topK:filter:returnDistance:returnMetadata:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorMatch: Codable, Sendable, Hashable { + /// The vector's unique key. + public var key: String + + /// The vector's embedding data, present when the request set `returnData: true`. + public var data: VectorData? + + /// The vector's metadata, present when the request set `returnMetadata: true`. + public var metadata: [String: AnyJSON]? + + /// The similarity distance from the query vector, present when the request set + /// `returnDistance: true`. Only populated by + /// ``VectorIndexClient/queryVectors(_:topK:filter:returnDistance:returnMetadata:)``. + public var distance: Double? +} + +/// A parallel scan segment for +/// ``VectorIndexClient/listVectors(maxResults:nextToken:returnData:returnMetadata:segment:)``. +/// +/// Splitting a list operation across `count` segments and listing each `index` concurrently lets +/// multiple workers scan a large index in parallel. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorListSegment: Sendable, Hashable { + /// The total number of parallel segments (1-16). + public var count: Int + + /// This segment's zero-based index (`0.. + - some: "NotFoundException" + - message: "resource \\"not-exist-bucket\\" not found" + ▿ statusCode: Optional + - some: "404" + + """ + } + } + } +} diff --git a/Tests/IntegrationTests/VectorIndexClientIntegrationTests.swift b/Tests/IntegrationTests/VectorIndexClientIntegrationTests.swift new file mode 100644 index 000000000..e85718d2d --- /dev/null +++ b/Tests/IntegrationTests/VectorIndexClientIntegrationTests.swift @@ -0,0 +1,138 @@ +// +// VectorIndexClientIntegrationTests.swift +// +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +@_spi(Experimental) import Storage +import Testing + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil)) +final class VectorIndexClientIntegrationTests { + let vectors = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_SECRET_KEY)" + ], + logger: nil + ) + ).vectors + + var bucketName = "" + + init() async throws { + bucketName = "test-vector-bucket-\(UUID().uuidString)" + try await vectors.createBucket(bucketName) + } + + // Async cleanup can outlive the test if the process exits immediately after — acceptable for + // local dev/CI cleanup, not correctness-critical. + deinit { + let vectors = vectors + let bucketName = bucketName + Task { + let bucket = vectors.from(bucketName) + if let indexes = try? await bucket.listIndexes() { + for index in indexes.indexes { + try? await bucket.deleteIndex(index.indexName) + } + } + try? await vectors.deleteBucket(bucketName) + } + } + + @Test + func index_CRUD() async throws { + let bucket = vectors.from(bucketName) + let indexName = "test-index" + + var page = try await bucket.listIndexes() + #expect(!page.indexes.contains { $0.indexName == indexName }) + + try await bucket.createIndex( + indexName, + dimension: 3, + distanceMetric: .cosine, + metadataConfiguration: VectorIndexMetadataConfiguration( + nonFilterableMetadataKeys: ["raw_text"] + ) + ) + + let index = try await bucket.getIndex(indexName) + #expect(index.indexName == indexName) + #expect(index.vectorBucketName == bucketName) + #expect(index.dataType == .float32) + #expect(index.dimension == 3) + #expect(index.distanceMetric == .cosine) + + page = try await bucket.listIndexes() + #expect(page.indexes.contains { $0.indexName == indexName }) + + try await bucket.deleteIndex(indexName) + + page = try await bucket.listIndexes() + #expect(!page.indexes.contains { $0.indexName == indexName }) + } + + @Test + func putGetListDeleteVectors() async throws { + let indexName = "test-index" + let bucket = vectors.from(bucketName) + try await bucket.createIndex(indexName, dimension: 3, distanceMetric: .cosine) + let index = bucket.index(indexName) + + try await index.putVectors([ + VectorEntry( + key: "a", data: VectorData(float32: [0.1, 0.2, 0.3]), metadata: ["type": "doc"]), + VectorEntry(key: "b", data: VectorData(float32: [0.4, 0.5, 0.6])), + ]) + + let fetched = try await index.getVectors( + keys: ["a", "b"], returnData: true, returnMetadata: true) + #expect(fetched.count == 2) + let a = try #require(fetched.first { $0.key == "a" }) + // The pgvector backend stores embeddings as `halfvec` (half precision), so components + // round-trip with reduced precision rather than exactly. + let aData = try #require(a.data?.float32) + for (actual, expected) in zip(aData, [0.1, 0.2, 0.3] as [Float]) { + #expect(abs(actual - expected) < 0.001) + } + #expect(a.metadata?["type"]?.stringValue == "doc") + + let listed = try await index.listVectors(returnData: true) + #expect(listed.vectors.map(\.key).sorted() == ["a", "b"]) + + try await index.deleteVectors(keys: ["a"]) + + let afterDelete = try await index.listVectors() + #expect(afterDelete.vectors.map(\.key) == ["b"]) + } + + @Test + func queryVectors() async throws { + let indexName = "test-index" + let bucket = vectors.from(bucketName) + try await bucket.createIndex(indexName, dimension: 2, distanceMetric: .cosine) + let index = bucket.index(indexName) + + try await index.putVectors([ + VectorEntry(key: "close", data: VectorData(float32: [1.0, 0.0])), + VectorEntry(key: "far", data: VectorData(float32: [0.0, 1.0])), + ]) + + let response = try await index.queryVectors( + VectorData(float32: [0.9, 0.1]), + topK: 2, + returnDistance: true + ) + #expect(response.vectors.map(\.key) == ["close", "far"]) + #expect(response.distanceMetric == .cosine) + } +} diff --git a/Tests/StorageTests/AnalyticsBucketClientTests.swift b/Tests/StorageTests/AnalyticsBucketClientTests.swift new file mode 100644 index 000000000..02afead25 --- /dev/null +++ b/Tests/StorageTests/AnalyticsBucketClientTests.swift @@ -0,0 +1,161 @@ +// +// AnalyticsBucketClientTests.swift +// Storage +// +// Created by Guilherme Souza on 06/08/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 AnalyticsBucketClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> AnalyticsBucketClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return AnalyticsClient( + 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("events") + } + + @Test + func createNamespaceDecodesNamespace() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces"), + statusCode: 200, + data: [ + .post: Data( + """ + {"namespace":["default"],"properties":{"owner":"team"}} + """.utf8 + ) + ] + ).register() + + let namespace = try await bucket.createNamespace("default", properties: ["owner": "team"]) + #expect(namespace.name == "default") + #expect(namespace.properties?["owner"] == "team") + } + + @Test + func getNamespaceDecodesNamespace() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default"), + statusCode: 200, + data: [ + .get: Data( + """ + {"namespace":["default"],"properties":{}} + """.utf8 + ) + ] + ).register() + + let namespace = try await bucket.getNamespace("default") + #expect(namespace.name == "default") + } + + @Test + func listNamespacesDecodesNamesAndNextPageToken() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + {"namespaces":[["default"],["prod"]],"next-page-token":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await bucket.listNamespaces(pageSize: 2) + #expect(response.namespaces == ["default", "prod"]) + #expect(response.nextPageToken == "page-2") + } + + @Test + func namespaceExistsReturnsTrue() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default"), + statusCode: 204, + data: [.head: Data()] + ).register() + + #expect(try await bucket.namespaceExists("default") == true) + } + + @Test + func namespaceExistsReturnsFalseOn404() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/missing"), + statusCode: 404, + data: [ + .head: Data( + """ + {"error":{"message":"namespace not found","type":"NoSuchNamespaceException","code":404}} + """.utf8 + ) + ] + ).register() + + #expect(try await bucket.namespaceExists("missing") == false) + } + + @Test + func deleteNamespace() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default"), + statusCode: 204, + data: [.delete: Data()] + ).register() + + try await bucket.deleteNamespace("default") + } + + @Test + func namespaceReturnsScopedClient() { + let bucket = makeSUT() + let namespace = bucket.namespace("default") + #expect(namespace.bucketName == "events") + #expect(namespace.namespaceName == "default") + } + } +} diff --git a/Tests/StorageTests/AnalyticsClientTests.swift b/Tests/StorageTests/AnalyticsClientTests.swift new file mode 100644 index 000000000..deae13efa --- /dev/null +++ b/Tests/StorageTests/AnalyticsClientTests.swift @@ -0,0 +1,138 @@ +// +// AnalyticsClientTests.swift +// Storage +// +// Created by Guilherme Souza on 06/08/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 AnalyticsClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> AnalyticsClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return AnalyticsClient( + 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 + ) + ) + ) + } + + @Test + func createBucketDecodesBucket() async throws { + let analytics = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/bucket"), + statusCode: 200, + data: [ + .post: Data( + """ + {"id":"events","name":"events","created_at":"2024-01-01T00:00:00.000Z",\ + "updated_at":"2024-01-01T00:00:00.000Z"} + """.utf8 + ) + ] + ).register() + + let bucket = try await analytics.createBucket("events") + #expect(bucket.id == "events") + #expect(bucket.name == "events") + } + + @Test + func deleteBucket() async throws { + let analytics = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/bucket/events"), + statusCode: 200, + data: [ + .delete: Data( + """ + {"message":"Successfully deleted"} + """.utf8) + ] + ).register() + + try await analytics.deleteBucket("events") + } + + @Test + func listBucketsDecodesArray() async throws { + let analytics = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/bucket"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + [{"id":"events","name":"events","created_at":"2024-01-01T00:00:00.000Z",\ + "updated_at":"2024-01-01T00:00:00.000Z"}] + """.utf8 + ) + ] + ).register() + + let buckets = try await analytics.listBuckets(sortColumn: .createdAt, sortOrder: .descending) + #expect(buckets.map(\.name) == ["events"]) + } + + @Test + func fromReturnsScopedClient() { + let analytics = makeSUT() + let bucket = analytics.from("events") + #expect(bucket.bucketName == "events") + } + + @Test + func forbiddenThrowsStorageErrorWithNestedShape() async throws { + let analytics = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/bucket"), + statusCode: 403, + data: [ + .post: Data( + """ + {"error":{"message":"new row violates row-level security","type":"Unauthorized","code":403}} + """.utf8 + ) + ] + ).register() + + let error = await #expect(throws: StorageError.self) { + try await analytics.createBucket("events") + } + #expect(error?.message == "new row violates row-level security") + #expect(error?.error == "Unauthorized") + #expect(error?.statusCode == "403") + } + } +} diff --git a/Tests/StorageTests/IcebergNamespaceClientTests.swift b/Tests/StorageTests/IcebergNamespaceClientTests.swift new file mode 100644 index 000000000..b873c9a2c --- /dev/null +++ b/Tests/StorageTests/IcebergNamespaceClientTests.swift @@ -0,0 +1,165 @@ +// +// IcebergNamespaceClientTests.swift +// Storage +// +// Created by Guilherme Souza on 06/08/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 IcebergNamespaceClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> IcebergNamespaceClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return AnalyticsClient( + 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("events").namespace("default") + } + + private static let loadTableResultJSON = """ + {"metadata-location":"s3://events/default/clicks/metadata/00000-abc.json",\ + "metadata":{"format-version":2,"table-uuid":"abc-123",\ + "location":"s3://events/default/clicks","last-updated-ms":1700000000000,\ + "properties":{"owner":"team"},\ + "schemas":[{"type":"struct","fields":[\ + {"id":1,"name":"id","type":"long","required":true},\ + {"id":2,"name":"url","type":"string","required":false}],"schema-id":0}],\ + "current-schema-id":0,\ + "partition-specs":[{"spec-id":0,"fields":[]}],\ + "default-spec-id":0}} + """ + + @Test + func createTableDecodesLoadTableResult() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables"), + statusCode: 200, + data: [.post: Data(Self.loadTableResultJSON.utf8)] + ).register() + + let result = try await namespace.createTable( + "clicks", + schema: IcebergSchema(fields: [ + IcebergStructField(id: 1, name: "id", type: "long", required: true), + IcebergStructField(id: 2, name: "url", type: "string", required: false), + ]) + ) + #expect(result.metadataLocation == "s3://events/default/clicks/metadata/00000-abc.json") + #expect(result.metadata.formatVersion == 2) + #expect(result.metadata.tableUUID == "abc-123") + #expect(result.metadata.schemas?.first?.fields.count == 2) + #expect(result.metadata.lastUpdatedAt == 1_700_000_000) + } + + @Test + func getTableDecodesLoadTableResult() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables/clicks"), + statusCode: 200, + data: [.get: Data(Self.loadTableResultJSON.utf8)] + ).register() + + let result = try await namespace.getTable("clicks") + #expect(result.metadata.tableUUID == "abc-123") + } + + @Test + func listTablesDecodesNamesAndNextPageToken() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + {"identifiers":[{"name":"clicks","namespace":["default"]},\ + {"name":"impressions","namespace":["default"]}],"next-page-token":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await namespace.listTables(pageSize: 2) + #expect(response.tables == ["clicks", "impressions"]) + #expect(response.nextPageToken == "page-2") + } + + @Test + func tableExistsReturnsTrue() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables/clicks"), + statusCode: 204, + data: [.head: Data()] + ).register() + + #expect(try await namespace.tableExists("clicks") == true) + } + + @Test + func tableExistsReturnsFalseOn404() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables/missing"), + statusCode: 404, + data: [ + .head: Data( + """ + {"error":{"message":"table not found","type":"NoSuchTableException","code":404}} + """.utf8 + ) + ] + ).register() + + #expect(try await namespace.tableExists("missing") == false) + } + + @Test + func deleteTablePassesPurgeRequested() async throws { + let namespace = makeSUT() + + Mock( + url: url.appendingPathComponent("iceberg/v1/events/namespaces/default/tables/clicks"), + ignoreQuery: true, + statusCode: 204, + data: [.delete: Data()] + ).register() + + try await namespace.deleteTable("clicks", purge: true) + } + } +} diff --git a/Tests/StorageTests/StorageVectorsClientTests.swift b/Tests/StorageTests/StorageVectorsClientTests.swift new file mode 100644 index 000000000..56304f1a7 --- /dev/null +++ b/Tests/StorageTests/StorageVectorsClientTests.swift @@ -0,0 +1,138 @@ +// +// 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( + 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 + ) + ) + ) + } + + @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") + } + } +} diff --git a/Tests/StorageTests/VectorBucketClientTests.swift b/Tests/StorageTests/VectorBucketClientTests.swift new file mode 100644 index 000000000..499721c93 --- /dev/null +++ b/Tests/StorageTests/VectorBucketClientTests.swift @@ -0,0 +1,158 @@ +// +// VectorBucketClientTests.swift +// Storage +// +// Created by Guilherme Souza on 06/08/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 VectorBucketClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> VectorBucketClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return StorageVectorsClient( + 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("documents") + } + + @Test + func createIndex() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateIndex"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await bucket.createIndex( + "embeddings", + dimension: 1536, + distanceMetric: .cosine, + metadataConfiguration: VectorIndexMetadataConfiguration( + nonFilterableMetadataKeys: ["raw_text"] + ) + ) + } + + @Test + func deleteIndex() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/DeleteIndex"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await bucket.deleteIndex("embeddings") + } + + @Test + func getIndexDecodesVectorIndex() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/GetIndex"), + statusCode: 200, + data: [ + .post: Data( + """ + {"index":{"indexName":"embeddings","vectorBucketName":"documents","dataType":"float32",\ + "dimension":1536,"distanceMetric":"cosine","creationTime":1730000000}} + """.utf8 + ) + ] + ).register() + + let index = try await bucket.getIndex("embeddings") + #expect(index.indexName == "embeddings") + #expect(index.vectorBucketName == "documents") + #expect(index.dataType == .float32) + #expect(index.dimension == 1536) + #expect(index.distanceMetric == .cosine) + #expect(index.creationTime == 1_730_000_000) + } + + @Test + func listIndexesDecodesIndexesAndNextToken() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/ListIndexes"), + statusCode: 200, + data: [ + .post: Data( + """ + {"indexes":[{"indexName":"embeddings","vectorBucketName":"documents"},\ + {"indexName":"summaries","vectorBucketName":"documents"}],"nextToken":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await bucket.listIndexes(prefix: "e", maxResults: 2) + #expect(response.indexes.map(\.indexName) == ["embeddings", "summaries"]) + #expect(response.nextToken == "page-2") + } + + @Test + func indexReturnsScopedClient() { + let bucket = makeSUT() + let index = bucket.index("embeddings") + #expect(index.vectorBucketName == "documents") + #expect(index.indexName == "embeddings") + } + + @Test + func forbiddenThrowsStorageError() async throws { + let bucket = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateIndex"), + 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 bucket.createIndex("embeddings", dimension: 1536, distanceMetric: .cosine) + } + #expect(error?.message == "new row violates row-level security") + #expect(error?.error == "Unauthorized") + } + } +} diff --git a/Tests/StorageTests/VectorIndexClientTests.swift b/Tests/StorageTests/VectorIndexClientTests.swift new file mode 100644 index 000000000..292aba993 --- /dev/null +++ b/Tests/StorageTests/VectorIndexClientTests.swift @@ -0,0 +1,177 @@ +// +// VectorIndexClientTests.swift +// Storage +// +// Created by Guilherme Souza on 06/08/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 VectorIndexClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> VectorIndexClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return StorageVectorsClient( + 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("documents").index("embeddings") + } + + @Test + func putVectors() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/PutVectors"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await index.putVectors([ + VectorEntry( + key: "doc-1", + data: VectorData(float32: [0.1, 0.2, 0.3]), + metadata: ["title": "Introduction"] + ) + ]) + } + + @Test + func deleteVectors() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/DeleteVectors"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await index.deleteVectors(keys: ["doc-1", "doc-2"]) + } + + @Test + func getVectorsDecodesVectors() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/GetVectors"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectors":[{"key":"doc-1","data":{"float32":[0.1,0.2,0.3]},"metadata":{"title":"Intro"}}]} + """.utf8 + ) + ] + ).register() + + let vectors = try await index.getVectors( + keys: ["doc-1"], returnData: true, returnMetadata: true) + #expect(vectors.count == 1) + #expect(vectors[0].key == "doc-1") + #expect(vectors[0].data?.float32 == [0.1, 0.2, 0.3]) + #expect(vectors[0].metadata?["title"]?.stringValue == "Intro") + } + + @Test + func listVectorsDecodesVectorsAndNextToken() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/ListVectors"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectors":[{"key":"doc-1"},{"key":"doc-2"}],"nextToken":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await index.listVectors( + maxResults: 2, segment: VectorListSegment(count: 4, index: 0)) + #expect(response.vectors.map(\.key) == ["doc-1", "doc-2"]) + #expect(response.nextToken == "page-2") + } + + @Test + func queryVectorsDecodesMatchesAndDistanceMetric() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/QueryVectors"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectors":[{"key":"doc-1","distance":0.12},{"key":"doc-2","distance":0.34}],\ + "distanceMetric":"cosine"} + """.utf8 + ) + ] + ).register() + + let response = try await index.queryVectors( + VectorData(float32: [0.1, 0.2, 0.3]), + topK: 5, + filter: ["category": "technical"], + returnDistance: true + ) + #expect(response.vectors.map(\.key) == ["doc-1", "doc-2"]) + #expect(response.vectors.map(\.distance) == [0.12, 0.34]) + #expect(response.distanceMetric == .cosine) + } + + @Test + func forbiddenThrowsStorageError() async throws { + let index = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/PutVectors"), + 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 index.putVectors([ + VectorEntry(key: "doc-1", data: VectorData(float32: [0.1])) + ]) + } + #expect(error?.message == "new row violates row-level security") + #expect(error?.error == "Unauthorized") + } + } +} diff --git a/dictionary.txt b/dictionary.txt index 91e27902b..1252ab110 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -38,10 +38,12 @@ decoratee deeplinking discardable dollarsign +dotproduct dont dragarcia dummysignature ecdsa +halfvec emaillink entrancy etag @@ -118,6 +120,7 @@ Passwordless Pathable PCREDENTIALW pgrst +pgvector phfts phraseto pkce