diff --git a/Package.swift b/Package.swift index a713bca0..c542c0c6 100644 --- a/Package.swift +++ b/Package.swift @@ -10,8 +10,8 @@ let package = Package( targets: [ .binaryTarget( name: "MossC", - url: "https://github.com/usemoss/moss/releases/download/v0.7.0/Moss.xcframework.zip", - checksum: "b8446db36039320cd1d073acb9e606d2dc7d8294253b45324b276fc50290bf61" + url: "https://github.com/usemoss/moss/releases/download/v0.8.0/Moss.xcframework.zip", + checksum: "4695db715bfbcfdd0d358fa7e0666e3d5be93e3d78af4779a785b522478ae338" ), .target( name: "MossRuntimeBridge", diff --git a/sdks/swift/Sources/Moss/MossClient.swift b/sdks/swift/Sources/Moss/MossClient.swift index b7f1dc7f..1e7b0af2 100644 --- a/sdks/swift/Sources/Moss/MossClient.swift +++ b/sdks/swift/Sources/Moss/MossClient.swift @@ -62,7 +62,7 @@ public final class MossClient: @unchecked Sendable { } } try Self.throwIfErr(r) - guard let raw else { throw Self.lastError(code: -7) } + guard let raw else { throw Self.missingResult() } self.handle = raw self.authUserData = nil } @@ -104,7 +104,7 @@ public final class MossClient: @unchecked Sendable { } guard let raw else { Unmanaged.fromOpaque(userData).release() - throw Self.lastError(code: -7) + throw Self.missingResult() } self.handle = raw self.authUserData = userData @@ -112,12 +112,13 @@ public final class MossClient: @unchecked Sendable { deinit { close() } - /// Free the underlying native handle and any authenticator box. + /// Send the final usage telemetry, then 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")`. + /// the call blocks until every borrowed handle is returned, then until + /// the telemetry is delivered or 2 seconds pass, then frees. After `close()` + /// returns, every further operation throws `MossError(-1, "MossClient already closed")`. public func close() { stateCond.lock() if closed { @@ -170,13 +171,17 @@ public final class MossClient: @unchecked Sendable { 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") + throw MossError(code: MossError.internalError, 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)") + throw MossError( + code: MossError.internalError, + message: "could not create the model cache directory", + detail: error.localizedDescription + ) } let r = dir.path.withCString { ptr in moss_set_model_cache_dir(ptr) } try throwIfErr(r) @@ -286,7 +291,7 @@ public final class MossClient: @unchecked Sendable { /// True when the linked native runtime exposes the multi-index C API. /// An xcframework built before that API returns `false` and every - /// multi-index call fails closed with `MossError(code: -7)`. + /// multi-index call fails closed with `MossError(code: MossError.internalError)`. public static var multiIndexAPIAvailable: Bool { moss_runtime_bridge_multi_index_available() != 0 } @@ -323,7 +328,7 @@ public final class MossClient: @unchecked Sendable { if let raw { moss_runtime_bridge_free_load_indexes_result(raw) } } try Self.throwIfErr(r) - guard let raw else { throw Self.lastError(code: -7) } + guard let raw else { throw Self.missingResult() } return Self.parseLoadIndexesResult(raw) } } @@ -362,7 +367,7 @@ public final class MossClient: @unchecked Sendable { options: QueryOptions = QueryOptions() ) async throws -> SearchResult { guard !names.isEmpty else { - throw MossError(code: -2, message: "names must contain at least one index name") + throw MossError(code: MossError.invalidArgument, message: "names must contain at least one index name") } try Self.requireMultiIndexAPI() try Self.requireNoNul(inIndexNames: names) @@ -391,7 +396,7 @@ public final class MossClient: @unchecked Sendable { } } try Self.throwIfErr(r) - guard let raw else { throw Self.lastError(code: -7) } + guard let raw else { throw Self.missingResult() } return Self.parseSearchResult( raw.assumingMemoryBound(to: MossSearchResult.self).pointee ) @@ -420,7 +425,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let result else { throw Self.missingResult() } defer { moss_free_search_result(result) } return Self.parseSearchResult(result.pointee) } @@ -452,7 +457,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let info else { throw Self.missingResult() } defer { moss_free_index_info(info) } return Self.parseIndexInfo(info.pointee) } @@ -489,7 +494,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let result else { throw Self.missingResult() } defer { moss_free_refresh_result(result) } let p = result.pointee return RefreshResult( @@ -510,7 +515,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let result else { throw Self.missingResult() } defer { moss_free_job_status_response(result) } let p = result.pointee return JobStatus( @@ -532,7 +537,7 @@ public final class MossClient: @unchecked Sendable { docs: [DocumentInfo], modelId: String? = nil ) async throws -> MutationResult { - try Self.requireNoNul(inIndexNames: [name]) + try Self.requireNoNul(inIndexNames: [name], docIds: docs.map(\.id)) let docsJson = try Self.encodeJson(docs) return try await Task.detached { [self] () throws -> MutationResult in let h = try borrowHandle() @@ -543,7 +548,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let out else { throw Self.missingResult() } defer { moss_free_string(out) } return try Self.decodeMutationResult(String(cString: out)) } @@ -557,7 +562,7 @@ public final class MossClient: @unchecked Sendable { docs: [DocumentInfo], upsert: Bool = true ) async throws -> MutationResult { - try Self.requireNoNul(inIndexNames: [name]) + try Self.requireNoNul(inIndexNames: [name], docIds: docs.map(\.id)) let docsJson = try Self.encodeJson(docs) return try await Task.detached { [self] () throws -> MutationResult in let h = try borrowHandle() @@ -567,7 +572,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let out else { throw Self.missingResult() } defer { moss_free_string(out) } return try Self.decodeMutationResult(String(cString: out)) } @@ -586,7 +591,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let out else { throw Self.missingResult() } defer { moss_free_string(out) } let str = String(cString: out) let data = Data(str.utf8) @@ -627,7 +632,7 @@ public final class MossClient: @unchecked Sendable { 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) } + guard let result else { throw Self.missingResult() } defer { moss_free_mutation_result(result) } let p = result.pointee return MutationResult( @@ -675,7 +680,7 @@ public final class MossClient: @unchecked Sendable { &raw ) try Self.throwIfErr(r) - guard let raw else { throw Self.lastError(code: -7) } + guard let raw else { throw Self.missingResult() } return MossSession(takingOwnershipOf: OpaquePointer(raw)) } } @@ -690,15 +695,16 @@ public final class MossClient: @unchecked Sendable { // ── Internals ──────────────────────────────────────────────────── - /// Throws for a name or document id holding U+0000. libmoss reads each - /// string up to its first NUL, so the rest would be dropped and the call - /// would reach a different index or document than the one the caller named. + /// Throws for a name or document id holding U+0000. + /// Names, and ids outside the JSON calls, reach libmoss as C strings + /// that end at the first NUL, so the call would name something else. + /// JSON ids are checked too, so every id this SDK stores can be deleted. static func requireNoNul(inIndexNames names: [String] = [], docIds: [String] = []) throws { if names.contains(where: { $0.utf8.contains(0) }) { - throw MossError(code: -2, message: "index names must not contain U+0000") + throw MossError(code: MossError.invalidArgument, message: "index names must not contain U+0000") } if docIds.contains(where: { $0.utf8.contains(0) }) { - throw MossError(code: -2, message: "document ids must not contain U+0000") + throw MossError(code: MossError.invalidArgument, message: "document ids must not contain U+0000") } } @@ -716,11 +722,11 @@ public final class MossClient: @unchecked Sendable { /// (Parent grouping is a session-only feature and is ignored here.) private static func resolveFilterJson(_ opts: QueryOptions) throws -> String? { guard opts.topK >= 0 else { - throw MossError(code: -2, message: "topK must be non-negative; got \(opts.topK)") + throw MossError(code: MossError.invalidArgument, message: "topK must be non-negative; got \(opts.topK)") } guard let f = opts.filter else { return opts.filterJson } guard let encoded = f.encoded() else { - throw MossError(code: -2, message: "could not encode metadata filter") + throw MossError(code: MossError.invalidArgument, message: "could not encode metadata filter") } return encoded } @@ -745,7 +751,7 @@ public final class MossClient: @unchecked Sendable { private static func requireMultiIndexAPI() throws { guard multiIndexAPIAvailable else { throw MossError( - code: -7, + code: MossError.internalError, message: "The linked Moss runtime does not provide the multi-index API (moss_client_query_multi_index); update Moss.xcframework" ) } @@ -769,7 +775,7 @@ public final class MossClient: @unchecked Sendable { stateCond.lock() defer { stateCond.unlock() } guard !closed, let h = handle else { - throw MossError(code: -1, message: "MossClient already closed") + throw MossError(code: MossError.nullPointer, message: "MossClient already closed") } inFlight += 1 return h @@ -799,7 +805,15 @@ public final class MossClient: @unchecked Sendable { 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) + let detailPtr = moss_runtime_bridge_last_error_detail() + let detail = detailPtr != nil ? String(cString: detailPtr!) : nil + return MossError(code: code, message: msg, detail: detail) + } + + /// A call reported success but handed back no result. The last error + /// belongs to an earlier call, so it is not read here. + static func missingResult() -> MossError { + MossError(code: MossError.internalError, message: "libmoss returned no result.") } fileprivate static func parseIndexInfo(_ i: MossIndexInfo) -> IndexInfo { @@ -821,7 +835,7 @@ public final class MossClient: @unchecked Sendable { 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") + throw MossError(code: MossError.internalError, message: "encoded JSON was not valid UTF-8") } return s } @@ -882,14 +896,20 @@ public final class MossClient: @unchecked Sendable { let failedCount = Int(moss_runtime_bridge_load_indexes_failed_count(raw)) var failed: [String: String] = [:] + var failedCodes: [String: Int32] = [:] failed.reserveCapacity(failedCount) for i in 0.. SearchResult { let opts = options guard opts.topK >= 0 else { - throw MossError(code: -2, message: "topK must be non-negative; got \(opts.topK)") + throw MossError(code: MossError.invalidArgument, message: "topK must be non-negative; got \(opts.topK)") } try Self.requireIdentityBoundTextInference() return try await Task.detached { [self] () throws -> SearchResult in @@ -336,7 +337,7 @@ public final class MossSession: @unchecked Sendable { var filterJson = opts.filterJson if let f = opts.filter { guard let encoded = f.encoded() else { - throw MossError(code: -2, message: "could not encode metadata filter") + throw MossError(code: MossError.invalidArgument, message: "could not encode metadata filter") } filterJson = encoded } @@ -359,7 +360,7 @@ public final class MossSession: @unchecked Sendable { let r: Int32 guard let api = MossIdentityBoundSessionAPI.shared else { throw MossError( - code: -5, + code: MossError.modelUnavailable, message: "This Moss native runtime cannot safely open provenance-sensitive sessions" ) } @@ -384,7 +385,7 @@ public final class MossSession: @unchecked Sendable { try MossClient.throwIfErr(r) return resultLocal }() - guard let result else { throw MossClient.lastError(code: -7) } + guard let result else { throw MossClient.missingResult() } defer { moss_free_search_result(result) } return MossClient.parseSearchResult(result.pointee) } @@ -404,7 +405,7 @@ public final class MossSession: @unchecked Sendable { ) async throws -> SearchResult { let opts = options guard opts.topK >= 0 else { - throw MossError(code: -2, message: "topK must be non-negative; got \(opts.topK)") + throw MossError(code: MossError.invalidArgument, message: "topK must be non-negative; got \(opts.topK)") } try Self.requireIdentityBoundTextInference() return try await Task.detached { [self] () throws -> SearchResult in @@ -413,7 +414,7 @@ public final class MossSession: @unchecked Sendable { var filterJson = opts.filterJson if let filter = opts.filter { guard let encoded = filter.encoded() else { - throw MossError(code: -2, message: "could not encode metadata filter") + throw MossError(code: MossError.invalidArgument, message: "could not encode metadata filter") } filterJson = encoded } @@ -424,7 +425,7 @@ public final class MossSession: @unchecked Sendable { try withOptionalCString(group?.orderField) { orderCString in guard let api = MossIdentityBoundSessionAPI.shared else { throw MossError( - code: -5, + code: MossError.modelUnavailable, message: "This Moss native runtime cannot safely open provenance-sensitive sessions" ) } @@ -458,7 +459,7 @@ public final class MossSession: @unchecked Sendable { } } try MossClient.throwIfErr(callResult) - guard let result else { throw MossClient.lastError(code: -7) } + guard let result else { throw MossClient.missingResult() } defer { moss_free_search_result(result) } return MossClient.parseSearchResult(result.pointee) } @@ -530,6 +531,10 @@ public final class MossSession: @unchecked Sendable { /// Restore a session from a previous `save(toCachePath:)` at /// `cachePath`. Returns the doc count restored. /// + /// The session adopts the model the snapshot records, except that a + /// session opened with `modelId: "custom"` and `autoLoadOnInit: false` + /// loads a foundation snapshot as `custom` until `loadIndex` succeeds in it. + /// /// Note: the session's *name* (passed to `client.session(_:)`) must /// match the one used at save time — it's part of the on-disk /// directory path. @@ -556,7 +561,7 @@ public final class MossSession: @unchecked Sendable { var raw: UnsafeMutablePointer? let r = moss_session_push_index(h, &raw) try MossClient.throwIfErr(r) - guard let raw else { throw MossClient.lastError(code: -7) } + guard let raw else { throw MossClient.missingResult() } defer { moss_free_push_index_result(raw) } let p = raw.pointee return PushIndexResult( @@ -574,7 +579,7 @@ public final class MossSession: @unchecked Sendable { stateCond.lock() defer { stateCond.unlock() } guard !closed, let h = handle else { - throw MossError(code: -1, message: "MossSession already closed") + throw MossError(code: MossError.nullPointer, message: "MossSession already closed") } inFlight += 1 return h diff --git a/sdks/swift/Sources/Moss/MossTypes.swift b/sdks/swift/Sources/Moss/MossTypes.swift index a351a170..c122b8c4 100644 --- a/sdks/swift/Sources/Moss/MossTypes.swift +++ b/sdks/swift/Sources/Moss/MossTypes.swift @@ -46,12 +46,16 @@ public struct SearchResult: Sendable { public struct LoadIndexesResult: Sendable { /// Names that are now loaded. public let loaded: [String] - /// Name to error message for the indexes that could not be loaded. + /// Name to fixed error message for the indexes that could not be loaded. public let failed: [String: String] + /// Name to `MossError` code for the indexes that could not be loaded. + /// Empty when the linked libmoss does not report codes. + public let failedCodes: [String: Int32] - public init(loaded: [String], failed: [String: String]) { + public init(loaded: [String], failed: [String: String], failedCodes: [String: Int32] = [:]) { self.loaded = loaded self.failed = failed + self.failedCodes = failedCodes } } @@ -210,13 +214,19 @@ public struct SessionOptions: Sendable { /// ```swift /// let session = try await client.session( /// name, - /// options: SessionOptions(modelId: "custom", autoLoadOnInit: false) + /// options: SessionOptions(modelId: "moss-minilm", autoLoadOnInit: false) /// ) /// if try await session.loadFromDisk(cachePath: cachePath) > 0 { return session } /// _ = try await session.loadIndex(name, options: LoadIndexOptions()) /// try await session.save(toCachePath: cachePath) /// ``` /// + /// Open the session with the index's foundation `modelId`. It checks the + /// credentials once when it opens, which needs the network, and `loadFromDisk` + /// then restores the saved model from disk with no download, so text queries work. + /// With `modelId: "custom"` that check is skipped, and a foundation snapshot loads + /// as `custom` until `loadIndex` succeeds, so use it only for explicit embeddings. + /// /// - Warning: with `false`, the session starts empty until you load it. /// `addDocs` only mutates the in-memory session, but calling `pushIndex()` /// on a session you never loaded pushes that near-empty session to the @@ -238,9 +248,11 @@ public struct SessionOptions: Sendable { public struct LoadIndexOptions: Sendable { /// Keep the loaded index in sync by polling the cloud in the background. public var autoRefresh: Bool - /// How often the auto-refresh poll runs, in seconds (only used when - /// `autoRefresh` is true). Defaults to 600 (10 minutes), and 0 uses that - /// default. The minimum is 1 second. + /// Seconds between auto-refresh polls. Defaults to 600 (10 minutes). + /// + /// The minimum is 1 second. `MossSession.loadIndex` reads 0 as 1 second. + /// `MossClient.loadIndex` and `loadIndexes` read 0 as 600 and cap it at one year. + /// Serving a cached index offline, they poll at this interval even with `autoRefresh` off. public var pollingIntervalSeconds: UInt64 /// Optional sandbox path used to cache the index on disk so subsequent /// launches don't re-download. Applies to `MossClient.loadIndex`; sessions @@ -352,7 +364,7 @@ public indirect enum Filter: Sendable { } /// Collapse sibling documents that share a parent identifier into one logical -/// result. Siblings are assembled in `orderField` order (numeric-aware). +/// result. Siblings are assembled in `orderField` order (numbers first, then text). public struct ParentGrouping: Sendable { public var parentField: String // e.g. "unit_id" public var orderField: String // e.g. "chunk_index" @@ -370,7 +382,7 @@ public struct GetDocsOptions: Sendable { public var ids: [String]? /// Metadata predicate; documents matching it are returned. public var filter: Filter? - /// Metadata field to order results by (numeric-aware). Ignored when `ids` + /// Metadata field to order results by (numbers first, then text). Ignored when `ids` /// already fixes the order. public var sortBy: String? /// Sort direction for `sortBy`. diff --git a/sdks/swift/Sources/MossRuntimeBridge/MossRuntimeBridge.c b/sdks/swift/Sources/MossRuntimeBridge/MossRuntimeBridge.c index b26ef3d8..661bf94f 100644 --- a/sdks/swift/Sources/MossRuntimeBridge/MossRuntimeBridge.c +++ b/sdks/swift/Sources/MossRuntimeBridge/MossRuntimeBridge.c @@ -10,6 +10,14 @@ uint64_t moss_runtime_bridge_session_capabilities(void) { #endif } +const char *moss_runtime_bridge_last_error_detail(void) { +#if defined(MOSS_ERROR_DETAIL_API_VERSION) && MOSS_ERROR_DETAIL_API_VERSION >= 1 + return moss_last_error_detail(); +#else + return NULL; +#endif +} + int32_t moss_runtime_bridge_client_session_v2( void *client, const char *name, @@ -251,6 +259,20 @@ const char *moss_runtime_bridge_load_indexes_failed_error_at(const void *result, #endif } +int32_t moss_runtime_bridge_load_indexes_failed_code_at(const void *result, uintptr_t i) { +#if defined(MOSS_ERROR_DETAIL_API_VERSION) && MOSS_ERROR_DETAIL_API_VERSION >= 1 + const MossLoadIndexesResult *r = (const MossLoadIndexesResult *)result; + if (r == NULL || r->failed_codes == NULL || i >= r->failed_count) { + return 0; + } + return (int32_t)r->failed_codes[i]; +#else + (void)result; + (void)i; + return 0; +#endif +} + void moss_runtime_bridge_free_load_indexes_result(void *result) { #if defined(MOSS_MULTI_INDEX_API_VERSION) && MOSS_MULTI_INDEX_API_VERSION >= 1 moss_free_load_indexes_result((MossLoadIndexesResult *)result); diff --git a/sdks/swift/Sources/MossRuntimeBridge/include/MossRuntimeBridge.h b/sdks/swift/Sources/MossRuntimeBridge/include/MossRuntimeBridge.h index 40188b12..dd987c74 100644 --- a/sdks/swift/Sources/MossRuntimeBridge/include/MossRuntimeBridge.h +++ b/sdks/swift/Sources/MossRuntimeBridge/include/MossRuntimeBridge.h @@ -8,6 +8,10 @@ extern "C" { #endif uint64_t moss_runtime_bridge_session_capabilities(void); + +/* Core error text behind the last failed call, from a libmoss whose header has + MOSS_ERROR_DETAIL_API_VERSION >= 1. NULL when there is none. */ +const char *moss_runtime_bridge_last_error_detail(void); typedef void (*MossRuntimeBridgeFunction)(void); int32_t moss_runtime_bridge_client_session_v2(void *client, const char *name, @@ -45,6 +49,9 @@ const char *moss_runtime_bridge_load_indexes_loaded_at(const void *result, uintp uintptr_t moss_runtime_bridge_load_indexes_failed_count(const void *result); const char *moss_runtime_bridge_load_indexes_failed_name_at(const void *result, uintptr_t i); const char *moss_runtime_bridge_load_indexes_failed_error_at(const void *result, uintptr_t i); +/* The MossResult for failed[i], or 0 when the linked libmoss does not report + codes (MOSS_ERROR_DETAIL_API_VERSION < 1). */ +int32_t moss_runtime_bridge_load_indexes_failed_code_at(const void *result, uintptr_t i); void moss_runtime_bridge_free_load_indexes_result(void *result); const char *moss_runtime_bridge_query_result_doc_index_name(const void *doc);