diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fd7162b --- /dev/null +++ b/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 5.9 +// +// Moss iOS SDK — Swift Package Manager manifest. +// +// The `Moss` library wraps the precompiled `MossC` xcframework hosted as a +// GitHub Release asset on this repo. Xcode downloads the binary on first +// resolve, verifies the SHA-256 checksum below, and links it into the +// consuming target. +// +// To consume from another package or app: +// +// dependencies: [ +// .package(url: "https://github.com/usemoss/moss", from: "0.1.0"), +// ], +// targets: [ +// .target(name: "YourTarget", dependencies: [ +// .product(name: "Moss", package: "moss"), +// ]), +// ] +// +// Or, in Xcode: File ▸ Add Package Dependencies ▸ https://github.com/usemoss/moss +// +// The Swift wrapper sources live under `sdks/swift/Sources/Moss/`. The +// xcframework binary is not committed to this repo — it ships as a release +// asset. Bump both the URL tag segment and the checksum together on every +// new tag. +import PackageDescription + +let package = Package( + name: "Moss", + platforms: [.iOS(.v15)], + products: [ + .library(name: "Moss", targets: ["Moss"]), + ], + targets: [ + .binaryTarget( + name: "MossC", + url: "https://github.com/usemoss/moss/releases/download/v0.1.0/Moss.xcframework.zip", + checksum: "08d78183b3eb94a372990373fd669101bb3292d5824680b2f5b826977a9ee924" + ), + .target( + name: "Moss", + dependencies: ["MossC"], + path: "sdks/swift/Sources/Moss" + ), + ] +) diff --git a/sdks/swift/LICENSE b/sdks/swift/LICENSE new file mode 100644 index 0000000..f536c68 --- /dev/null +++ b/sdks/swift/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2025 InferEdge Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sdks/swift/README.md b/sdks/swift/README.md new file mode 100644 index 0000000..fedd75e --- /dev/null +++ b/sdks/swift/README.md @@ -0,0 +1,121 @@ +# Moss Swift SDK + +The Swift SDK for [Moss](https://github.com/usemoss/moss) — fast on-device search for iOS. + +## Requirements + +- iOS 15+ +- Xcode 15+ + +## Install + +In Xcode: **File ▸ Add Package Dependencies…** and enter + +``` +https://github.com/usemoss/moss +``` + +Pick the latest version under "Up to Next Major Version". + +Or in `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/usemoss/moss", from: "0.1.0"), +], +targets: [ + .target(name: "YourTarget", dependencies: [ + .product(name: "Moss", package: "moss"), + ]), +] +``` + +## Quick start + +```swift +import Moss + +let client = try MossClient(projectId: "your_project_id", projectKey: "your_project_key") +defer { client.close() } + +// Create an index, load it, query. +_ = try await client.createIndex("support-docs", docs: [ + .init(id: "1", text: "Refunds are processed within 3-5 business days."), + .init(id: "2", text: "You can track your order on the dashboard."), +]) + +try await client.loadIndex("support-docs") + +let result = try await client.query("support-docs", "how long do refunds take?") +for doc in result.docs { + print(String(format: "[%.3f] %@", doc.score, doc.text)) +} +``` + +## Authentication + +Two ways to authenticate: + +**Static project key** — simplest, fine for prototyping: + +```swift +let client = try MossClient(projectId: id, projectKey: key) +``` + +**`Authenticator` protocol** — for apps that fetch short-lived tokens from +your backend: + +```swift +final class MyAuth: Authenticator { + func getAuthHeader() async throws -> String { + // Fetch / refresh a bearer token from your server. + return try await myServer.fetchToken() + } +} + +let client = try MossClient(projectId: id, authenticator: MyAuth()) +``` + +## Memory pressure + +When the OS sends a memory warning, ask the SDK to drop reclaimable +caches: + +```swift +NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main +) { _ in + Task { _ = try? await client.onMemoryPressure(.critical) } +} +``` + +On-disk caches are kept; only in-memory structures are freed. Next +`loadIndex` rehydrates from disk. + +## Threading + +All operations are `async throws` and dispatch work onto a background +thread. The underlying client is thread-safe — you can share a single +`MossClient` across the app. + +## Custom cache directory (advanced) + +`MossClient` automatically caches model files under +`/moss-models/`. To point it somewhere else — e.g. a +shared App Group container so multiple targets share the same models — +call `setModelCacheDir` *before* constructing your first client: + +```swift +try MossClient.setModelCacheDir("/path/to/your/cache") +let client = try MossClient(projectId: id, projectKey: key) +``` + +## Reporting issues + +Open an issue at . + +## License + +[BSD 2-Clause](./LICENSE) diff --git a/sdks/swift/Sources/Moss/Authenticator.swift b/sdks/swift/Sources/Moss/Authenticator.swift new file mode 100644 index 0000000..93d4770 --- /dev/null +++ b/sdks/swift/Sources/Moss/Authenticator.swift @@ -0,0 +1,51 @@ +import Foundation +import MossC + +/// Implement to inject a custom auth flow into [MossClient]. +/// +/// The native runtime calls [getAuthHeader] whenever it needs a fresh bearer +/// token for an outbound request. Implementations typically fetch the token +/// from your backend (and cache it until expiry). +/// +/// ## Return-value contract +/// +/// Return **the raw bearer token only** — do **not** include the `Bearer ` +/// prefix or any other Authorization-header decoration: +/// +/// ```swift +/// // ✅ correct +/// return "eyJhbGciOi..." +/// // ❌ wrong — the SDK prepends `Bearer ` itself +/// return "Bearer eyJhbGciOi..." +/// ``` +/// +/// The Swift wrapper passes this string directly to the native side, which +/// constructs the full `Authorization: Bearer ` header. The JS SDK's +/// `IAuthenticator.getAuthHeader()` happens to use the opposite convention +/// (returns the full `Bearer ...` value); that's because the JS SDK builds +/// the request in JS userland rather than going through the native C ABI. +/// Don't copy the JS convention here. +/// +/// Implementations must be safe to call from any thread; the native side may +/// invoke from a background worker. +public protocol Authenticator: AnyObject, Sendable { + func getAuthHeader() async throws -> String +} + +// ── Internal C-callback dispatch ───────────────────────────────────── + +/// Holds a strong reference to the user's authenticator so the C callback can +/// dispatch back. The pointer to this box becomes the `user_data` passed to +/// `moss_client_new_with_authenticator`. The actual C trampoline lives in +/// MossClient.swift to avoid Swift emitting duplicate `@_cdecl` symbols +/// across translation units that reference it (eager linking would +/// otherwise reject the build). +/// +/// `@unchecked Sendable`: the box only holds an immutable `any Authenticator`, +/// and the `Authenticator` protocol itself requires `Sendable`. The Swift +/// compiler can't see that across the `Unmanaged.fromOpaque` boundary — +/// hence `@unchecked`. +final class AuthenticatorBox: @unchecked Sendable { + let inner: any Authenticator + init(_ inner: any Authenticator) { self.inner = inner } +} diff --git a/sdks/swift/Sources/Moss/MossClient.swift b/sdks/swift/Sources/Moss/MossClient.swift new file mode 100644 index 0000000..d191b99 --- /dev/null +++ b/sdks/swift/Sources/Moss/MossClient.swift @@ -0,0 +1,642 @@ +import Foundation +import MossC + +/// Idiomatic Swift wrapper for the native Moss SDK. +/// +/// Construct with either a static project key or an [Authenticator]. +/// All methods are `async throws` and dispatch native work onto a background +/// thread. The underlying native client is thread-safe. +/// +/// ```swift +/// let client = try MossClient(projectId: "p", projectKey: "k") +/// defer { client.close() } +/// +/// try await client.loadIndex("docs", options: .init(cachePath: cachePath)) +/// let result = try await client.query("docs", "vector search on mobile") +/// ``` +public final class MossClient: @unchecked Sendable { + /// Opaque pointer to the native MossClient. Mutated only behind + /// `stateCond`; access from outside the lock is unsafe because a + /// concurrent `close()` could free it. Operations borrow it via + /// `borrowHandle()` which both reads it and increments `inFlight` in + /// a single critical section. + private var handle: OpaquePointer? + /// Authenticator-backed clients retain an opaque pointer to an + /// `AuthenticatorBox` (`Unmanaged.passRetained`) as the native side's + /// user_data. `close()` releases it once, after the in-flight count + /// has drained. + private var authUserData: UnsafeMutableRawPointer? + /// Number of operations that have called `borrowHandle()` but not yet + /// `returnHandle()`. `close()` waits on `stateCond` until this drops + /// to zero before freeing the native handle, so an in-flight call + /// never operates on a freed pointer. + private var inFlight: Int = 0 + /// Once true, no new `borrowHandle()` succeeds. Set by `close()` + /// before it begins waiting for `inFlight` to drain. + private var closed: Bool = false + /// Mutex + condition variable guarding `handle`, `authUserData`, + /// `inFlight`, and `closed`. `close()` signals here when ops finish. + private let stateCond = NSCondition() + + /// Construct a client backed by a static project key. + public init(projectId: String, projectKey: String) throws { + try Self.ensureModelCacheDir() + var raw: OpaquePointer? + let r = projectId.withCString { pid in + projectKey.withCString { pkey in + moss_client_new(pid, pkey, &raw) + } + } + try Self.throwIfErr(r) + guard let raw else { throw Self.lastError(code: -7) } + self.handle = raw + self.authUserData = nil + } + + /// Construct a client whose bearer tokens come from [authenticator]. + public init(projectId: String, authenticator: any Authenticator, baseUrl: String? = nil) throws { + try Self.ensureModelCacheDir() + let box = AuthenticatorBox(authenticator) + // Retain the box and pass its raw pointer as user_data. The native + // side stores it for the client's lifetime. `close()` releases the + // retained reference exactly once. + let userData = Unmanaged.passRetained(box).toOpaque() + + var raw: OpaquePointer? + let r = projectId.withCString { pid in + withOptionalCString(baseUrl) { base in + moss_client_new_with_authenticator( + pid, + mossSwiftAuthNotify, + userData, + base, + &raw + ) + } + } + if r != 0 { + // Ownership returns to us so the box is freed on the error path. + Unmanaged.fromOpaque(userData).release() + try Self.throwIfErr(r) + } + guard let raw else { + Unmanaged.fromOpaque(userData).release() + throw Self.lastError(code: -7) + } + self.handle = raw + self.authUserData = userData + } + + deinit { close() } + + /// Free the underlying native handle and any authenticator box. + /// + /// Idempotent. Safe to call concurrently with in-flight operations: + /// the call blocks until every borrowed handle is returned, then + /// frees. After `close()` returns, every further operation throws + /// `MossError(-1, "MossClient already closed")`. + public func close() { + stateCond.lock() + if closed { + stateCond.unlock() + return + } + closed = true + while inFlight > 0 { + stateCond.wait() + } + let h = handle + handle = nil + let ud = authUserData + authUserData = nil + stateCond.unlock() + + if let h { moss_client_free(h) } + if let ud { Unmanaged.fromOpaque(ud).release() } + } + + public static var sdkVersion: String { + String(cString: moss_sdk_version()) + } + + /// Point the embedding-model cache at a custom directory. + /// + /// **You normally don't need to call this.** `MossClient` automatically + /// caches model files under `/moss-models/` on first + /// init, which works for almost every app. + /// + /// Call this only if you want a different location (e.g. a shared App + /// Group container). Call it *before* constructing your first + /// `MossClient`; later overrides still take effect but the default may + /// have already been wired. + /// + /// Throws `MossError` if `path` is empty or not valid UTF-8. + public static func setModelCacheDir(_ path: String) throws { + cacheDirLock.lock(); defer { cacheDirLock.unlock() } + let r = path.withCString { ptr in moss_set_model_cache_dir(ptr) } + try throwIfErr(r) + cacheDirConfigured = true + } + + /// Auto-wires the model cache to `/moss-models/` if no + /// caller has overridden it via `setModelCacheDir`. The native default + /// home-directory lookup doesn't resolve inside an iOS app sandbox, so + /// without this hook the first `loadIndex` / `query` would fail with + /// a much less actionable `ErrModel`. + private static func ensureModelCacheDir() throws { + cacheDirLock.lock(); defer { cacheDirLock.unlock() } + if cacheDirConfigured { return } + guard let cacheRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { + throw MossError(code: -7, message: "could not locate for model cache") + } + let dir = cacheRoot.appendingPathComponent("moss-models", isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + throw MossError(code: -7, message: "could not create model cache directory at \(dir.path): \(error.localizedDescription)") + } + let r = dir.path.withCString { ptr in moss_set_model_cache_dir(ptr) } + try throwIfErr(r) + cacheDirConfigured = true + } + + /// Guards `cacheDirConfigured` against races between `setModelCacheDir` + /// (caller thread) and `ensureModelCacheDir` (any init thread). + private static let cacheDirLock = NSLock() + private static var cacheDirConfigured = false + + // ── Operations ─────────────────────────────────────────────────── + + public func loadIndex(_ name: String, options: LoadIndexOptions = LoadIndexOptions()) async throws { + let opts = options + try await Task.detached { [self] in + let h = try borrowHandle() + defer { returnHandle() } + try name.withCString { cname in + try withOptionalCString(opts.cachePath) { cachePath in + var nativeOpts = MossLoadIndexOptions( + auto_refresh: opts.autoRefresh, + polling_interval_secs: opts.pollingIntervalSeconds, + cache_path: cachePath + ) + var info: UnsafeMutablePointer? + let r = moss_client_load_index(h, cname, &nativeOpts, &info) + if let info { moss_free_index_info(info) } + try Self.throwIfErr(r) + } + } + }.value + } + + public func unloadIndex(_ name: String) async throws { + try await Task.detached { [self] in + let h = try borrowHandle() + defer { returnHandle() } + try name.withCString { cname in + let r = moss_client_unload_index(h, cname) + try Self.throwIfErr(r) + } + }.value + } + + public func query( + _ indexName: String, + _ query: String, + options: QueryOptions = QueryOptions() + ) async throws -> SearchResult { + let opts = options + // Validate topK eagerly so the caller gets a descriptive error + // instead of `UInt(opts.topK)` trapping on negatives. + guard opts.topK >= 0 else { + throw MossError(code: -2, message: "topK must be non-negative; got \(opts.topK)") + } + return try await Task.detached { [self] () throws -> SearchResult in + let h = try borrowHandle() + defer { returnHandle() } + return try indexName.withCString { iname in + try query.withCString { q in + try withOptionalCString(opts.filterJson) { filter in + var nativeOpts = MossQueryOptions( + top_k: UInt(opts.topK), + alpha: opts.alpha, + filter_json: filter, + embedding: nil, + embedding_dim: 0 + ) + var result: UnsafeMutablePointer? + let r = moss_client_query(h, iname, q, &nativeOpts, &result) + try Self.throwIfErr(r) + guard let result else { throw Self.lastError(code: -7) } + defer { moss_free_search_result(result) } + return Self.parseSearchResult(result.pointee) + } + } + } + }.value + } + + public func deleteIndex(_ name: String) async throws -> Bool { + try await Task.detached { [self] () throws -> Bool in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { (cname: UnsafePointer) throws -> Bool in + var deleted: Bool = false + let r = moss_client_delete_index(h, cname, &deleted) + try Self.throwIfErr(r) + return deleted + } + }.value + } + + public func getIndex(_ name: String) async throws -> IndexInfo { + try await Task.detached { [self] () throws -> IndexInfo in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { cname in + var info: UnsafeMutablePointer? + let r = moss_client_get_index(h, cname, &info) + try Self.throwIfErr(r) + guard let info else { throw Self.lastError(code: -7) } + defer { moss_free_index_info(info) } + return Self.parseIndexInfo(info.pointee) + } + }.value + } + + public func listIndexes() async throws -> [IndexInfo] { + try await Task.detached { [self] () throws -> [IndexInfo] in + let h = try borrowHandle() + defer { returnHandle() } + var infos: UnsafeMutablePointer? + var count: UInt = 0 + let r = moss_client_list_indexes(h, &infos, &count) + try Self.throwIfErr(r) + guard let infos else { return [] } + defer { moss_free_index_info_list(infos, count) } + let n = Int(count) + var out: [IndexInfo] = [] + out.reserveCapacity(n) + for i in 0.. RefreshResult { + try await Task.detached { [self] () throws -> RefreshResult in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { cname in + var result: UnsafeMutablePointer? + let r = moss_client_refresh_index(h, cname, &result) + try Self.throwIfErr(r) + guard let result else { throw Self.lastError(code: -7) } + defer { moss_free_refresh_result(result) } + let p = result.pointee + return RefreshResult( + indexName: cstr(p.index_name), + previousUpdatedAt: cstr(p.previous_updated_at), + newUpdatedAt: cstr(p.new_updated_at), + wasUpdated: p.was_updated + ) + } + }.value + } + + public func getJobStatus(_ jobId: String) async throws -> JobStatus { + try await Task.detached { [self] () throws -> JobStatus in + let h = try borrowHandle() + defer { returnHandle() } + return try jobId.withCString { cjob in + var result: UnsafeMutablePointer? + let r = moss_client_get_job_status(h, cjob, &result) + try Self.throwIfErr(r) + guard let result else { throw Self.lastError(code: -7) } + defer { moss_free_job_status_response(result) } + let p = result.pointee + return JobStatus( + jobId: cstr(p.job_id), + status: cstr(p.status), + progress: p.progress, + currentPhase: cstrOpt(p.current_phase), + error: cstrOpt(p.error), + createdAt: cstr(p.created_at), + updatedAt: cstr(p.updated_at), + completedAt: cstrOpt(p.completed_at) + ) + } + }.value + } + + public func createIndex( + _ name: String, + docs: [DocumentInfo], + modelId: String? = nil + ) async throws -> MutationResult { + let docsJson = try Self.encodeJson(docs) + return try await Task.detached { [self] () throws -> MutationResult in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { cname in + try docsJson.withCString { cdocs in + try withOptionalCString(modelId) { cmodel in + var out: UnsafeMutablePointer? + let r = moss_client_create_index_from_json(h, cname, cdocs, cmodel, &out) + try Self.throwIfErr(r) + guard let out else { throw Self.lastError(code: -7) } + defer { moss_free_string(out) } + return try Self.decodeMutationResult(String(cString: out)) + } + } + } + }.value + } + + public func addDocs( + _ name: String, + docs: [DocumentInfo], + upsert: Bool = true + ) async throws -> MutationResult { + let docsJson = try Self.encodeJson(docs) + return try await Task.detached { [self] () throws -> MutationResult in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { cname in + try docsJson.withCString { cdocs in + var out: UnsafeMutablePointer? + let r = moss_client_add_docs_from_json(h, cname, cdocs, upsert, &out) + try Self.throwIfErr(r) + guard let out else { throw Self.lastError(code: -7) } + defer { moss_free_string(out) } + return try Self.decodeMutationResult(String(cString: out)) + } + } + }.value + } + + public func getDocs(_ name: String, docIds: [String]? = nil) async throws -> [DocumentInfo] { + let idsJson: String? = try docIds.map { try Self.encodeJson($0) } + return try await Task.detached { [self] () throws -> [DocumentInfo] in + let h = try borrowHandle() + defer { returnHandle() } + return try name.withCString { cname in + try withOptionalCString(idsJson) { cids in + var out: UnsafeMutablePointer? + let r = moss_client_get_docs_json(h, cname, cids, &out) + try Self.throwIfErr(r) + guard let out else { throw Self.lastError(code: -7) } + defer { moss_free_string(out) } + let str = String(cString: out) + let data = Data(str.utf8) + return try JSONDecoder().decode([DocumentInfo].self, from: data) + } + } + }.value + } + + /// Free reclaimable native memory in response to an OS memory-pressure + /// signal. Wire this from `applicationDidReceiveMemoryWarning` / + /// `UIApplication.didReceiveMemoryWarningNotification`. Returns the + /// number of indexes that were unloaded. + public func onMemoryPressure(_ level: MemoryPressureLevel = .critical) async throws -> Int { + let levelRaw = level.rawValue + return try await Task.detached { [self] () throws -> Int in + let h = try borrowHandle() + defer { returnHandle() } + var unloaded: Int = 0 + // `MossMemoryPressure` is the same cbindgen-generated enum/typedef + // pair as `MossResult` — pass the raw UInt8 value to avoid the + // ambiguous-type lookup error. + let r = moss_client_release_memory(h, levelRaw, &unloaded) + try Self.throwIfErr(r) + return unloaded + }.value + } + + public func deleteDocs(_ name: String, docIds: [String]) async throws -> MutationResult { + try await Task.detached { [self] () throws -> MutationResult in + let h = try borrowHandle() + defer { returnHandle() } + // Build a const-char-pointer array; the C function takes + // `const char *const *` plus a count. + return try name.withCString { cname in + try withCStringArray(docIds) { ptrs in + var result: UnsafeMutablePointer? + let r = moss_client_delete_docs(h, cname, ptrs, UInt(docIds.count), &result) + try Self.throwIfErr(r) + guard let result else { throw Self.lastError(code: -7) } + defer { moss_free_mutation_result(result) } + let p = result.pointee + return MutationResult( + jobId: cstr(p.job_id), + indexName: cstr(p.index_name), + docCount: Int(p.doc_count) + ) + } + } + }.value + } + + // ── Internals ──────────────────────────────────────────────────── + + /// Reserve the native handle for the duration of a single operation. + /// Increments `inFlight` so a concurrent `close()` blocks until the + /// matching `returnHandle()` runs. Must be paired with exactly one + /// `returnHandle()` (use `defer`). + private func borrowHandle() throws -> OpaquePointer { + stateCond.lock() + defer { stateCond.unlock() } + guard !closed, let h = handle else { + throw MossError(code: -1, message: "MossClient already closed") + } + inFlight += 1 + return h + } + + /// Release a handle reservation taken with `borrowHandle()`. Wakes a + /// waiting `close()` when `inFlight` drops to zero. + private func returnHandle() { + stateCond.lock() + defer { stateCond.unlock() } + inFlight -= 1 + if inFlight == 0 { + stateCond.broadcast() + } + } + + /// `MossResult` is emitted by cbindgen as both an `enum` and a separate + /// `typedef int32_t MossResult`, which Swift sees as ambiguous. We treat + /// the value as a raw `Int32` and compare against the well-known OK == 0 + /// constant from the C header. + fileprivate static func throwIfErr(_ r: Int32) throws { + if r != 0 { + throw lastError(code: r) + } + } + + fileprivate static func lastError(code: Int32) -> MossError { + let ptr = moss_last_error() + let msg = ptr != nil ? String(cString: ptr!) : "moss native error code \(code)" + return MossError(code: code, message: msg) + } + + fileprivate static func parseIndexInfo(_ i: MossIndexInfo) -> IndexInfo { + IndexInfo( + id: cstr(i.id), + name: cstr(i.name), + status: cstr(i.status), + docCount: Int(i.doc_count), + model: ModelRef( + id: cstr(i.model.id), + version: cstrOpt(i.model.version) + ), + version: cstrOpt(i.version), + createdAt: cstrOpt(i.created_at), + updatedAt: cstrOpt(i.updated_at) + ) + } + + fileprivate static func encodeJson(_ value: T) throws -> String { + let data = try JSONEncoder().encode(value) + guard let s = String(data: data, encoding: .utf8) else { + throw MossError(code: -7, message: "encoded JSON was not valid UTF-8") + } + return s + } + + fileprivate static func decodeMutationResult(_ json: String) throws -> MutationResult { + // The JSON-returning C entry points (moss_client_create_index_from_json, + // moss_client_add_docs_from_json) emit MutationResult with camelCase + // keys: { "jobId", "indexName", "docCount" }. If the native layer's + // serialization format ever changes, the JSONDecoder call below will + // throw with a clear "keyNotFound" error. + struct Wire: Decodable { + let jobId: String + let indexName: String + let docCount: Int + } + let w = try JSONDecoder().decode(Wire.self, from: Data(json.utf8)) + return MutationResult(jobId: w.jobId, indexName: w.indexName, docCount: w.docCount) + } + + private static func parseSearchResult(_ r: MossSearchResult) -> SearchResult { + let count = Int(r.doc_count) + var docs: [QueryResult] = [] + docs.reserveCapacity(count) + if let buf = r.docs { + for i in 0..?, + count: UInt + ) -> [String: String]? { + guard let entries, count > 0 else { return nil } + var out: [String: String] = [:] + let n = Int(count) + out.reserveCapacity(n) + for i in 0...fromOpaque(userData).takeUnretainedValue() + Task.detached { + do { + let token = try await box.inner.getAuthHeader() + token.withCString { ptr in _ = moss_resolve_auth_request(requestId, ptr) } + } catch { + let msg = "\(error)" + msg.withCString { ptr in _ = moss_reject_auth_request(requestId, ptr) } + } + } +} + +/// `withCString` for an optional string. Calls `body(nil)` when the input is nil. +@inline(__always) +func withOptionalCString(_ s: String?, _ body: (UnsafePointer?) throws -> R) rethrows -> R { + if let s { + return try s.withCString { try body($0) } + } else { + return try body(nil) + } +} + +/// Build a `const char *const *` array of NUL-terminated UTF-8 copies of +/// `strings`, hand it to `body`, then free everything. Used for C +/// functions that take arrays of strings (e.g. `moss_client_delete_docs`). +/// +/// Allocates with Swift's `UnsafeMutablePointer.allocate`, which traps on +/// failure rather than returning nil — so the produced pointer array is +/// always fully populated by the time `body` runs. +@inline(__always) +func withCStringArray( + _ strings: [String], + _ body: (UnsafePointer?>) throws -> R +) rethrows -> R { + let buffers: [UnsafeMutablePointer] = strings.map { s in + let utf8 = Array(s.utf8) + let buf = UnsafeMutablePointer.allocate(capacity: utf8.count + 1) + for (i, b) in utf8.enumerated() { + buf[i] = CChar(bitPattern: b) + } + buf[utf8.count] = 0 + return buf + } + defer { buffers.forEach { $0.deallocate() } } + let ptrs = UnsafeMutablePointer?>.allocate(capacity: buffers.count) + defer { ptrs.deallocate() } + for (i, b) in buffers.enumerated() { + ptrs[i] = UnsafePointer(b) + } + return try body(ptrs) +} + +/// Read a (possibly NULL) `*mut c_char` into a Swift String, defaulting to empty. +@inline(__always) +func cstr(_ p: UnsafeMutablePointer?) -> String { + p.flatMap { String(cString: $0) } ?? "" +} + +/// Read a (possibly NULL) `*mut c_char` into a Swift String?, returning nil +/// for null pointers. +@inline(__always) +func cstrOpt(_ p: UnsafeMutablePointer?) -> String? { + p.flatMap { String(cString: $0) } +} diff --git a/sdks/swift/Sources/Moss/MossError.swift b/sdks/swift/Sources/Moss/MossError.swift new file mode 100644 index 0000000..5943fcd --- /dev/null +++ b/sdks/swift/Sources/Moss/MossError.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Surfaced for any failure reported by the underlying Moss runtime. +public struct MossError: LocalizedError { + public let code: Int32 + public let message: String + + public var errorDescription: String? { message } + + init(code: Int32, message: String) { + self.code = code + self.message = message + } +} diff --git a/sdks/swift/Sources/Moss/MossTypes.swift b/sdks/swift/Sources/Moss/MossTypes.swift new file mode 100644 index 0000000..7c2e451 --- /dev/null +++ b/sdks/swift/Sources/Moss/MossTypes.swift @@ -0,0 +1,116 @@ +import Foundation + +public struct QueryResult: Sendable { + public let id: String + public let score: Float + public let text: String + /// Metadata associated with the document at index time, surfaced for + /// inspection and filtering. `nil` when the document has no metadata; + /// values are always strings (matching the native key/value model). + public let metadata: [String: String]? + + public init(id: String, score: Float, text: String, metadata: [String: String]? = nil) { + self.id = id + self.score = score + self.text = text + self.metadata = metadata + } +} + +public struct SearchResult: Sendable { + public let docs: [QueryResult] + public let query: String + public let timeMs: UInt64 +} + +public struct QueryOptions: Sendable { + public var topK: Int + /// Hybrid weight between dense (1.0) and sparse (0.0) scores. + public var alpha: Float + /// Optional metadata filter as a JSON string. + public var filterJson: String? + + public init(topK: Int = 5, alpha: Float = 0.8, filterJson: String? = nil) { + self.topK = topK + self.alpha = alpha + self.filterJson = filterJson + } +} + +/// A document stored in or returned from a Moss index. +public struct DocumentInfo: Sendable, Codable { + public let id: String + public let text: String + public let metadata: [String: String]? + public let embedding: [Float]? + + public init(id: String, text: String, metadata: [String: String]? = nil, embedding: [Float]? = nil) { + self.id = id + self.text = text + self.metadata = metadata + self.embedding = embedding + } +} + +/// Levels reported by the host OS when memory is constrained. +public enum MemoryPressureLevel: UInt8, Sendable { + /// Hint: drop hot caches. + case low = 0 + /// Drop everything reclaimable; persisted on-disk caches are kept. + case critical = 1 +} + +public struct ModelRef: Sendable { + public let id: String + public let version: String? +} + +public struct IndexInfo: Sendable { + public let id: String + public let name: String + public let status: String + public let docCount: Int + public let model: ModelRef + public let version: String? + public let createdAt: String? + public let updatedAt: String? +} + +public struct RefreshResult: Sendable { + public let indexName: String + public let previousUpdatedAt: String + public let newUpdatedAt: String + public let wasUpdated: Bool +} + +public struct MutationResult: Sendable { + public let jobId: String + public let indexName: String + public let docCount: Int +} + +public struct JobStatus: Sendable { + public let jobId: String + public let status: String + public let progress: Double + public let currentPhase: String? + public let error: String? + public let createdAt: String + public let updatedAt: String + public let completedAt: String? +} + +public struct LoadIndexOptions: Sendable { + public var autoRefresh: Bool + public var pollingIntervalSeconds: UInt64 + /// Optional sandbox path used to cache the index on disk so subsequent + /// launches don't re-download. Pass `FileManager.default.urls(for: + /// .documentDirectory, in: .userDomainMask).first!.path` or similar. + public var cachePath: String? + + public init(autoRefresh: Bool = false, pollingIntervalSeconds: UInt64 = 0, cachePath: String? = nil) { + self.autoRefresh = autoRefresh + self.pollingIntervalSeconds = pollingIntervalSeconds + self.cachePath = cachePath + } +}