Sign in

    chroma-client

    A Chroma vector database HTTP client for MoonBit: collections, records, queries and embeddings.

    chroma
    vector-database
    embedding
    rag
    http-client
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 days ago
    Downloads
    12

    #SundayT1998/chroma-client

    A Chroma vector-database client for MoonBit.

    Chroma is the embedding database commonly used behind retrieval-augmented generation (RAG) pipelines. chroma-client speaks Chroma's v2 HTTP API so MoonBit programs can create collections, add and query documents, and manage records without leaving the language.

    #Docs

    The README is the entry point; everything longer lives in docs/:

    #Design

    The library builds requests and decodes responses, and delegates the actual sockets to a trait. It ships the transport real applications use — AsyncHttpTransport, backed by moonbitlang/async/http — and leaves the trait open so an application with its own HTTP stack can plug it in. There are two traits:

    • AsyncTransport — asynchronous, with async fn send_async. This is the one the bundled AsyncHttpTransport implements, because moonbitlang/async/http only offers async functions and an async call cannot be made from a synchronous one.

    • Transport — synchronous. MemoryTransport implements it, so scripted responses cover the whole request/response cycle in ordinary moon test runs.

    Every public method exists in both flavours: client.add(...) is synchronous, client.add_async(...) is asynchronous. Choosing between them is choosing which trait your transport implements and whether your call site is async.

    The library ships its own transport, AsyncHttpTransport, backed by moonbitlang/async/http: a Chroma client that cannot reach a server is not useful, and native + wasm both run on the async stack the ecosystem already has. Callers get a working client out of the box, and the transport is:

    • portable — the same code runs on native, wasm and js. The transport limits the library to the backends moonbitlang/async supports; wasm-gc has no async runtime yet, so it is not a supported target;
    • testable — MemoryTransport implements the synchronous Transport and replays scripted responses, so the whole request/response cycle is covered by ordinary moon test runs without a server;
    • replaceable — AsyncTransport is a two-method-wide seam, so an application that already owns an HTTP stack can plug it in instead of using the bundled one.

    #Install

    moon add SundayT1998/chroma-client

    #Quick start

    ///|
    async fn example() -> Unit raise @chroma.ChromaError {
    // `AsyncHttpTransport` is bundled; point the config at your Chroma server.
    let client = @chroma.ChromaClient::new(
    @chroma.AsyncHttpTransport::new(),
    config=@chroma.Config::new(endpoint="http://localhost:8000"),
    )

    let collection = client.get_or_create_collection_async("notes")

    // Chroma 1.x stores vectors, not text: every record needs coordinates.
    client.add_async(collection.id, [
    @chroma.Record::of_embedding("r1", [1.0, 0.0, 0.0]).with_document(
    "MoonBit is a language for the web",
    ),
    @chroma.Record::of_embedding("r2", [0.0, 1.0, 0.0]).with_document(
    "Chroma stores embeddings",
    ),
    ])

    let result = client.query_embeddings_async(
    collection.id,
    [[1.0, 0.0, 0.0]],
    n_results=2,
    )
    for id in result.ids {
    println(id)
    }
    }

    AsyncHttpTransport ships with the library (see Native transport), so most callers never write a transport. If your application already owns an HTTP stack, implement @chroma.AsyncTransport instead — see Adding a real transport.

    Chroma 1.x has no server-side embedding. An add whose records carry documents but no vectors is rejected by the server, so the client refuses it locally and points at Record::with_embedding. Produce the vectors with whatever embedding model you already use; Record::of_document(...).with_embedding(vec) is the usual shape.

    #Choosing what a query returns

    QueryResult mirrors Chroma's include field:

    client.query_embeddings_async(
    collection.id,
    [[0.1, 0.9, 0.0]],
    n_results=5,
    include_fields=@chroma.Include::all(),
    )

    Include::default() returns documents, metadata and distances; Include::ids_only() returns just ids.

    #Filtering

    Filter builds Chroma's where / where_document clauses:

    let filter = @chroma.Filter::new()
    .with_metadata("source", @chroma.MString("handbook"))
    .with_limit(20)
    client.get_async(collection.id, filter=filter)

    #Metadata types

    Chroma accepts only scalar metadata. MetadataValue models that directly:

    @chroma.MetadataValue::MString("hello")
    @chroma.MetadataValue::MInt(42)
    @chroma.MetadataValue::MFloat(0.5)
    @chroma.MetadataValue::MBool(true)
    @chroma.MetadataValue::MNull

    Collections and records take a Metadata value, which is an ordered list of (key, MetadataValue) pairs.

    #Adding a real transport

    Implement @chroma.AsyncTransport against whatever HTTP stack you use. With moonbitlang/async/http, the whole transport is a dozen lines:

    ///|
    impl @chroma.AsyncTransport for MyTransport with fn send_async(self, request) {
    let headers : @ah.Headers = Map([])
    for entry in request.headers {
    let (key, value) = entry
    headers[key] = value
    }
    let payload : &@aio.Data = match request.body {
    Some(body) => body
    None => ""
    }
    let (response, response_body) = @ah.request(
    request.url,
    to_ah_method(request.verb),
    headers,
    payload,
    )
    {
    status: response.code,
    headers: converted(response.headers),
    body: response_body.text(),
    }
    }

    Request carries the verb, URL, headers and optional JSON body; the transport only has to return a Response.

    That is exactly what the bundled AsyncHttpTransport does; its source is async_http_transport.mbt. With moonbitlang/async/http the impl alone is not enough: MoonBit also needs an extend declaration to make the method callable:

    ///|
    pub extend MyTransport with @chroma.AsyncTransport::{send_async}

    #Native transport

    AsyncHttpTransport (in the library) is wired to moonbitlang/async/http, which is the only HTTP client in the ecosystem that runs on native:

    moon run cmd/main --target native -- http://localhost:8000 my-collection

    It creates a collection, adds vectors, counts, queries with distances, applies a metadata filter, and deletes the collection — a full round-trip against a real server, and the fastest way to check that a local Chroma is reachable.

    For a long-lived application, hold one @ah.Client open and reuse it rather than connecting per request: @ah.Client keeps the connection alive, while the example's @ah.request helper does not.

    #Authentication

    Config carries the headers every request shares. The common schemes have shorthands so they do not have to be spelled out by hand:

    ///|
    let client = @chroma.ChromaClient::new(
    transport,
    config=@chroma.Config::new()
    .with_token("my-api-token")
    .with_basic_auth("user", "password"),
    )

    with_headers merges a whole map, and with_header sets a single value. All four return a copy, so the original configuration is never mutated.

    #Timeouts

    The library performs no I/O, so it cannot cancel a socket read by itself. TimeoutTransport makes the deadline explicit instead: it stamps x-chroma-timeout-ms on every request, and the real transport is expected to honour it.

    ///|
    let client = @chroma.ChromaClient::new(
    @chroma.TimeoutTransport::new(http_stack, 5000),
    // or, to pick up Config::timeout_ms:
    // @chroma.TimeoutTransport::from_config(http_stack, config)
    )

    A timeout_ms of 0 or less disables the wrapper and passes requests through untouched.

    #Counting and previewing

    ///|
    let total = client.count(collection.id)

    ///|
    let sample = client.peek(collection.id, limit=5)

    peek is get with a limit, but the limit is clamped to PEEK_LIMIT (100) so a preview can never turn into a full scan.

    #Pagination

    Filter carries limit and offset, and Chroma requires offset to be smaller than limit — it walks a page, it does not skip past the end of one. Violating that raises InvalidArgument locally instead of costing a round-trip and an opaque 400:

    ///|
    client.get_async(
    collection.id,
    filter=@chroma.Filter::new(limit=Some(10), offset=Some(5)),
    )

    query_embeddings accepts n_results and rejects values <= 0 the same way. On a query Chroma only honours where / where_document, so limit, offset and ids are dropped rather than forwarded.

    #Records and vectors

    Chroma stores a vector for every record; the document, when present, is the text that vector stands for. The client models that directly:

    ///|
    // Vector first, text attached afterwards.
    @chroma.Record::of_embedding("r1", [1.0, 0.0]).with_document("hello")

    ///|
    // Text first, vector attached afterwards.
    @chroma.Record::of_document("r2", "world").with_embedding([0.0, 1.0])

    ///|
    // Metadata, either way.
    @chroma.Record::of_embedding("r3", [1.0, 1.0]).with_metadata([
    ("source", @chroma.MString("handbook")),
    ])

    add and upsert require the vector; update does not, because an update patches a record by id and a missing field means "leave this one alone". A batch that add would have to reject is refused before a request is built, so the failure names the record instead of arriving as a server-side 422.

    #Errors

    Every fallible call raises ChromaError:

    • Transport(String) — the request could not be sent;
    • Api(ApiError) — the server answered non-2xx with Chroma's error body;
    • Status(Int, String) — the server answered non-2xx with a body that is not a Chroma error envelope (a proxy page, say);
    • Decode(String) — the response body was not the expected JSON;
    • InvalidArgument(String) — the arguments would be rejected by Chroma, so nothing was sent.

    ApiError splits the response so no caller has to parse it again: status, error_code (Chroma's short label), message (the sentence), details (any extra structure) and the raw body. A couple of helpers cover the usual branches:

    ///|
    match result {
    Err(error) =>
    if error.status_code() == Some(404) {
    // nothing there
    } else if error.is_retryable() {
    // 429 or 5xx, worth trying again
    } else {
    println(error.message())
    }
    Ok(value) => ignore(value)
    }

    #Retrying

    Retrying is opt-in: a plain client sends one request and reports what came back. Wrapping the transport with a RetryPolicy turns on retries for 429 and 5xx only — a 4xx is a deterministic answer, so repeating it just burns a round-trip.

    ///|
    let client = @chroma.ChromaClient::new(
    @chroma.RetryTransport::new(
    http_stack,
    policy=@chroma.RetryPolicy::new(
    enabled=true,
    max_attempts=3,
    initial_delay_ms=200,
    max_delay_ms=5000,
    backoff_factor=2.0,
    ),
    ),
    )

    The library still performs no I/O, so it never sleeps. Each retry is announced through on_retry(attempt, delay_ms, error), and the delay is stamped on the request as x-chroma-retry-delay-ms for a transport that can wait:

    ///|
    let transport = @chroma.RetryTransport::new(http_stack).on_retry((
    attempt,
    delay_ms,
    error,
    ) => {
    println(
    "retry " +
    attempt.to_string() +
    " in " +
    delay_ms.to_string() +
    "ms: " +
    error.to_string(),
    )
    })

    For example, in a js transport the callback is where setTimeout belongs.

    #Response size

    A response body is read into one String, so the client caps it at MAX_RESPONSE_BYTES (32 MiB) and raises Decode past it. Truncating would turn a memory problem into silent data loss, so the client fails loudly instead; the fix on the caller's side is to narrow include, lower n_results, or page with limit. Error bodies are read before the cap is applied, so a long error message still reaches the caller.

    #Local validation

    Arguments Chroma would reject outright are caught before a request is built, so a bug in your code surfaces as a InvalidArgument with a sentence rather than a 400 and a round-trip:

    • an empty records batch on add / upsert / update;
    • a record with an empty id, or with neither a document nor an embedding;
    • an empty embeddings list on query_embeddings;
    • n_results <= 0, and an offset that is not smaller than limit.

    The checks are exposed as validate_records, validate_query_inputs, validate_n_results and validate_limit_offset if you build requests yourself.

    #Decoding rules

    The client distinguishes fields Chroma always returns from fields that depend on the request:

    • required and missing ⇒ Decode. Collection.id / Collection.name and the ids of a get result are never silently defaulted, because an empty list and a broken response are different answers;
    • optional and missing ⇒ empty default. metadata, configuration, log_position, version, and everything include controls (documents, embeddings, metadatas, distances) may legitimately be absent.

    #Examples

    cmd/main is a single runnable example: it talks to a real Chroma server through the library's AsyncHttpTransport — see Native transport above.

    moon run cmd/main --target native -- http://localhost:8000 my-collection

    #Testing

    Two layers, both run by CI. docs/CONTRIBUTING.md has the full version, including when a change needs a live test. The gates a change has to clear are listed there too — in short, everything green via sh scripts/ci.sh, including a core-path coverage floor of 80%.

    The unit suites need nothing but moon: the synchronous half is driven through MemoryTransport, and the _async half through the package-private ScriptedAsyncTransport (see scripted_transport_wbtest.mbt), because an async method cannot be driven from the synchronous double.

    sh scripts/ci.sh

    It runs, in order: formatting, moon check, generated-interface freshness, tests with coverage, coverage reports, a core-path coverage gate (>= 80%), and debug + release builds. A single step can be run on its own:

    sh scripts/ci.sh fmt check # just formatting and type-checking sh scripts/ci.sh test coverage # just tests and coverage

    The live suite (chroma_wbtest.mbt) runs against a real Chroma server and is what caught the protocol bugs the unit tests could not: scripted responses only prove the client agrees with itself. It shares the root package with the client, and the transport it drives (AsyncHttpTransport, backed by moonbitlang/async/http) is exported by the library, so the code under test is exactly what an application runs. Those tests skip when CHROMA_ENDPOINT is unset, so scripts/ci.sh stays runnable without Docker. To run them, start a server and point the suite at it:

    sh scripts/integration.sh # starts chromadb/chroma, runs, tears down

    or, against a server you already have:

    CHROMA_ENDPOINT=http://localhost:8000 \ moon test --target native -p SundayT1998/chroma-client

    Both CI platforms run them: CNB starts the container through the docker service in .cnb.yml, and GitHub Actions uses a services: container on ubuntu-latest only, since the service container does not exist on macOS or Windows.

    Set CHROMA_TEST_RUN_ID when several jobs share one server: each run namespaces its collection names with it, so parallel runs cannot collide.

    #Status

    Early but functional, and verified against a live server rather than only against scripted responses. The v2 collection, record and query endpoints are implemented, including count and peek; token and basic authentication have shorthands on Config, and tenant/database selection is part of the config. Server errors are parsed into ApiError, transient failures can be retried explicitly, and request arguments are validated locally before they are sent.

    Both a synchronous and an asynchronous API exist, and the wire format matches Chroma 1.x: columnar record bodies, get_or_create, bare-integer counts, and no text-to-vector conversion on the server.

    #License

    Apache-2.0.

    Embedding

    type Embedding = Array[Double]

    A vector embedding as returned or requested by Chroma.

    Metadata

    type Metadata = Array[(String, MetadataValue)]

    Ordered list of (key, value) metadata pairs.

    AsyncTransport

    pub(open) trait AsyncTransport {
    async fn send_async(Self, Request) -> Response
    }

    The asynchronous counterpart of Transport.

    Transport is synchronous, which is enough for MemoryTransport but not for the real HTTP stack: moonbitlang/async/http only exposes async functions, and an async call cannot be made from a synchronous one. A native or wasm build therefore implements this trait instead, and the client methods gain an async suffix.

    The two traits carry the same request and response types, so switching between a scripted test and a live server changes only which send is implemented.

    Transport

    pub(open) trait Transport {
    fn send(Self, Request) -> Response raise
    }

    Minimal transport abstraction.

    chroma-client is deliberately transport-agnostic: the library builds requests and decodes responses while the host application supplies the actual I/O. MemoryTransport covers tests.

    ChromaError

    pub(all) suberror ChromaError {
    Transport(String)
    Status(Int, String)
    Api(ApiError)
    Decode(String)
    InvalidArgument(String)
    }

    Errors surfaced by the client.

    ChromaError::equal

    fn ChromaError::equal(self : ChromaError, other : ChromaError) -> Bool

    ChromaError::is_retryable

    fn ChromaError::is_retryable(self : ChromaError) -> Bool

    Whether the error is worth retrying: 429 and 5xx, i.e. the server was reachable but too busy or broken. Client errors (4xx except 429) are deterministic and repeating them only wastes a round-trip.

    ChromaError::message

    fn ChromaError::message(self : ChromaError) -> String

    The message a caller can show a user, without the transport wrapper.

    ChromaError::not_equal

    fn ChromaError::not_equal(x : ChromaError, y : ChromaError) -> Bool

    ChromaError::output

    fn ChromaError::output(self : ChromaError, logger : &Logger) -> Unit

    ChromaError::status_code

    fn ChromaError::status_code(self : ChromaError) -> Int?

    Extracts the numeric status of any server-side response error.

    None means the error never reached the server, so retrying it is the caller's decision rather than a given.

    ChromaError::to_string

    fn ChromaError::to_string(self : ChromaError) -> String

    ApiError

    pub(all) struct ApiError {
    status : Int
    error_code : String
    message : String
    body : String
    details : Json
    } derive(Eq,
    Debug
    )

    A non-2xx response, split into the fields Chroma documents.

    Callers get the message without writing a parser or a regular expression: error is the short machine-readable label, message the human sentence, and details whatever extra structure the server attached.

    ApiError::equal

    fn ApiError::equal(ApiError, ApiError) -> Bool

    ApiError::from_json

    fn ApiError::from_json(status : Int, body : String) -> ApiError

    Decodes a non-2xx body into an ApiError.

    Chroma answers with {"error": "...", "message": "..."} on most paths. Both fields are optional in practice: a proxy or an older server may send only one, or neither, so nothing here is treated as a hard decode failure and the raw body is always preserved.

    ApiError::not_equal

    fn ApiError::not_equal(x : ApiError, y : ApiError) -> Bool

    ApiError::output

    fn ApiError::output(self : ApiError, logger : &Logger) -> Unit

    ApiError::to_repr

    ApiError::to_string

    fn ApiError::to_string(self : ApiError) -> String

    AsyncHttpTransport

    pub struct AsyncHttpTransport {
    }

    A ChromaClient transport backed by moonbitlang/async/http.

    This is the piece that turns the client's request objects into real sockets. It implements AsyncTransport, so it plugs into the *_async methods, and it is deliberately thin: it maps the client's method/header vocabulary onto @ah, sends one request, and reads the whole body back.

    It lives in the library rather than in a test or example package because a Chroma client that cannot reach a server is not useful: preferred_target= "wasm" describes where the client runs, and this transport is how it talks to Chroma from native and wasm alike.

    One connection per request is enough for a demo and for a test suite. A long-lived application that cares about latency should keep an @ah.Client open and reuse it, because @ah.Client keeps the connection alive across requests; see the "Native transport" section of the README.

    The transport holds no state: the client already puts the full URL in every request, so there is nothing to join here.

    AsyncHttpTransport::new

    Builds a transport.

    AsyncHttpTransport::send_async

    async fn AsyncHttpTransport::send_async(self : AsyncHttpTransport, request : Request) -> Response

    ChromaClient

    pub(all) struct ChromaClient[T] {
    transport : T
    config : Config
    }

    A Chroma client bound to one transport and one server configuration.

    The client is generic over its transport so applications can plug in any HTTP implementation, and tests can use MemoryTransport. It is otherwise stateless: every call is a plain HTTP request.

    ChromaClient::add

    fn[T : Transport] ChromaClient::add(self : ChromaClient[T], collection_id : String, records : Array[Record]) -> Unit raise ChromaError

    Adds records to a collection.

    Every record must carry either a document or an embedding, otherwise Chroma rejects the request.

    ChromaClient::add_async

    async fn[T : AsyncTransport] ChromaClient::add_async(self : ChromaClient[T], collection_id : String, records : Array[Record]) -> Unit raise ChromaError

    Adds records to a collection.

    ChromaClient::config

    fn[T] ChromaClient::config(self : ChromaClient[T]) -> Config

    Returns the configuration this client was built with.

    ChromaClient::count

    fn[T : Transport] ChromaClient::count(self : ChromaClient[T], collection_id : String) -> Int raise ChromaError

    Counts the records in a collection.

    ChromaClient::count_async

    async fn[T : AsyncTransport] ChromaClient::count_async(self : ChromaClient[T], collection_id : String) -> Int raise ChromaError

    Counts the records in a collection.

    ChromaClient::count_collections

    fn[T : Transport] ChromaClient::count_collections(self : ChromaClient[T]) -> Int raise ChromaError

    Counts the collections in the configured tenant and database.

    ChromaClient::count_collections_async

    async fn[T : AsyncTransport] ChromaClient::count_collections_async(self : ChromaClient[T]) -> Int raise ChromaError

    Counts the collections in the configured tenant and database.

    ChromaClient::create_collection

    fn[T : Transport] ChromaClient::create_collection(self : ChromaClient[T], name : String, config? : CollectionConfig) -> Collection raise ChromaError

    Creates a collection, failing when name is already taken.

    Sends get_or_create: false, so the server answers 409 rather than silently returning a collection that may have a different configuration. Use get_or_create_collection when either outcome is fine.

    ChromaClient::create_collection_async

    async fn[T : AsyncTransport] ChromaClient::create_collection_async(self : ChromaClient[T], name : String, config? : CollectionConfig) -> Collection raise ChromaError

    Creates a collection, failing when name is already taken.

    ChromaClient::delete

    fn[T : Transport] ChromaClient::delete(self : ChromaClient[T], collection_id : String, filter? : Filter) -> Unit raise ChromaError

    Deletes records matching the filter. With an empty filter Chroma deletes every record in the collection.

    ChromaClient::delete_async

    async fn[T : AsyncTransport] ChromaClient::delete_async(self : ChromaClient[T], collection_id : String, filter? : Filter) -> Unit raise ChromaError

    Deletes records matching the filter.

    ChromaClient::delete_collection

    fn[T : Transport] ChromaClient::delete_collection(self : ChromaClient[T], name : String) -> Unit raise ChromaError

    Deletes a collection by name.

    ChromaClient::delete_collection_async

    async fn[T : AsyncTransport] ChromaClient::delete_collection_async(self : ChromaClient[T], name : String) -> Unit raise ChromaError

    Deletes a collection by name.

    ChromaClient::get

    fn[T : Transport] ChromaClient::get(self : ChromaClient[T], collection_id : String, filter? : Filter) -> QueryResult raise ChromaError

    Fetches records by filter, or the whole collection when the filter is empty.

    ChromaClient::get_async

    async fn[T : AsyncTransport] ChromaClient::get_async(self : ChromaClient[T], collection_id : String, filter? : Filter) -> QueryResult raise ChromaError

    Fetches records by filter, or the whole collection when the filter is empty.

    ChromaClient::get_collection

    fn[T : Transport] ChromaClient::get_collection(self : ChromaClient[T], name : String) -> Collection? raise ChromaError

    Fetches an existing collection by name, returning None on 404.

    ChromaClient::get_collection_async

    async fn[T : AsyncTransport] ChromaClient::get_collection_async(self : ChromaClient[T], name : String) -> Collection? raise ChromaError

    Fetches an existing collection by name, returning None on 404.

    ChromaClient::get_or_create_collection

    fn[T : Transport] ChromaClient::get_or_create_collection(self : ChromaClient[T], name : String, config? : CollectionConfig) -> Collection raise ChromaError

    Returns a collection handle for name, creating it when it is missing.

    Sends get_or_create: true, so calling it twice finds the collection instead of colliding on the second call.

    ChromaClient::get_or_create_collection_async

    async fn[T : AsyncTransport] ChromaClient::get_or_create_collection_async(self : ChromaClient[T], name : String, config? : CollectionConfig) -> Collection raise ChromaError

    Returns a collection handle for name, creating it when it is missing.

    ChromaClient::heartbeat

    fn[T : Transport] ChromaClient::heartbeat(self : ChromaClient[T]) -> Int64 raise ChromaError

    Checks that the server is reachable and returns its heartbeat timestamp.

    Chroma answers {"nanosecond heartbeat": <ns since the epoch>}. The nanoseconds do not fit an Int32, so the value is returned as an Int64; a server that answers with a bare number (or with an unexpected shape) is accepted too, since the point of the call is reachability rather than the timestamp itself.

    ChromaClient::heartbeat_async

    async fn[T : AsyncTransport] ChromaClient::heartbeat_async(self : ChromaClient[T]) -> Int64 raise ChromaError

    Checks that the server is reachable and returns its heartbeat timestamp.

    ChromaClient::list_collections

    fn[T : Transport] ChromaClient::list_collections(self : ChromaClient[T]) -> Array[Collection] raise ChromaError

    Lists every collection in the configured tenant and database.

    ChromaClient::list_collections_async

    async fn[T : AsyncTransport] ChromaClient::list_collections_async(self : ChromaClient[T]) -> Array[Collection] raise ChromaError

    Lists every collection in the configured tenant and database.

    ChromaClient::modify_collection

    fn[T : Transport] ChromaClient::modify_collection(self : ChromaClient[T], name : String, new_name? : String?, metadata? : Array[(String, MetadataValue)]?) -> Unit raise ChromaError

    Updates a collection's name and/or metadata.

    new_name of None keeps the current name. name may be the collection's name or its id; a name is looked up first, because the server's PUT endpoint only accepts an id.

    ChromaClient::modify_collection_async

    async fn[T : AsyncTransport] ChromaClient::modify_collection_async(self : ChromaClient[T], name : String, new_name? : String?, metadata? : Array[(String, MetadataValue)]?) -> Unit raise ChromaError

    Updates a collection's name and/or metadata.

    new_name of None keeps the current name. name may be the collection's name or its id; a name is looked up first, because the server's PUT endpoint only accepts an id (see resolve_collection_id).

    ChromaClient::new

    fn[T] ChromaClient::new(transport : T, config? : Config) -> ChromaClient[T]

    Binds a transport and configuration into a client.

    ChromaClient::peek

    fn[T : Transport] ChromaClient::peek(self : ChromaClient[T], collection_id : String, limit? : Int, include_fields? : Include) -> QueryResult raise ChromaError

    Returns a small sample of the records in a collection.

    limit is capped at peek_limit so a stray large value cannot turn a preview into a full scan of the collection.

    ChromaClient::peek_async

    async fn[T : AsyncTransport] ChromaClient::peek_async(self : ChromaClient[T], collection_id : String, limit? : Int, include_fields? : Include) -> QueryResult raise ChromaError

    Returns a small sample of the records in a collection.

    limit is capped at peek_limit so a stray large value cannot turn a preview into a full scan of the collection.

    ChromaClient::query_embeddings

    fn[T : Transport] ChromaClient::query_embeddings(self : ChromaClient[T], collection_id : String, embeddings : Array[Array[Double]], n_results? : Int, include_fields? : Include, filter? : Filter) -> QueryResult raise ChromaError

    Searches a collection with caller-supplied query vectors.

    ChromaClient::query_embeddings_async

    async fn[T : AsyncTransport] ChromaClient::query_embeddings_async(self : ChromaClient[T], collection_id : String, embeddings : Array[Array[Double]], n_results? : Int, include_fields? : Include, filter? : Filter) -> QueryResult raise ChromaError

    Searches a collection with caller-supplied query vectors.

    ChromaClient::update

    fn[T : Transport] ChromaClient::update(self : ChromaClient[T], collection_id : String, records : Array[Record], filter? : Filter) -> Unit raise ChromaError

    Updates the document, metadata or embedding of existing records.

    Records are matched by id; ids that do not exist are ignored by Chroma.

    ChromaClient::update_async

    async fn[T : AsyncTransport] ChromaClient::update_async(self : ChromaClient[T], collection_id : String, records : Array[Record], filter? : Filter) -> Unit raise ChromaError

    Updates the document, metadata or embedding of existing records.

    Records are matched by id; ids that do not exist are ignored by Chroma.

    ChromaClient::upsert

    fn[T : Transport] ChromaClient::upsert(self : ChromaClient[T], collection_id : String, records : Array[Record]) -> Unit raise ChromaError

    Inserts or replaces records by id.

    ChromaClient::upsert_async

    async fn[T : AsyncTransport] ChromaClient::upsert_async(self : ChromaClient[T], collection_id : String, records : Array[Record]) -> Unit raise ChromaError

    Inserts or replaces records by id.

    ChromaClient::version

    fn[T : Transport] ChromaClient::version(self : ChromaClient[T]) -> String raise ChromaError

    Returns the server version string.

    ChromaClient::version_async

    async fn[T : AsyncTransport] ChromaClient::version_async(self : ChromaClient[T]) -> String raise ChromaError

    Returns the server version string.

    Collection

    pub(all) struct Collection {
    id : String
    name : String
    metadata : Array[(String, MetadataValue)]
    configuration : Json
    log_position : Int
    version : Int
    } derive(Eq,
    Debug
    )

    A Chroma collection as returned by the server.

    Collection::equal

    fn Collection::equal(Collection, Collection) -> Bool

    Collection::from_json

    fn Collection::from_json(json : Json) -> Collection raise ChromaError

    Decodes a collection from a server response object.

    id and name are required: a response without them is not a collection, and silently defaulting a name to "" would only push the failure further down the line. The remaining fields are optional and default to empty so that responses from older servers still decode.

    Collection::not_equal

    fn Collection::not_equal(x : Collection, y : Collection) -> Bool

    CollectionConfig

    pub(all) struct CollectionConfig {
    metadata : Array[(String, MetadataValue)]
    configuration : Json?
    } derive(Eq,
    Debug
    )

    Parameters for get_or_create_collection and create_collection.

    configuration is sent verbatim, so index options the server knows about (for example {"hnsw": {"space": "cosine"}}) can be set without waiting for this client to model them. When it is unset a default L2 HNSW configuration is sent.

    CollectionConfig::equal

    CollectionConfig::new

    fn CollectionConfig::new(metadata? : Array[(String, MetadataValue)], configuration? : Json?) -> CollectionConfig

    Builds a CollectionConfig with all options unset.

    CollectionConfig::not_equal

    fn CollectionConfig::not_equal(x : CollectionConfig, y : CollectionConfig) -> Bool

    CollectionConfig::to_json

    fn CollectionConfig::to_json(config : CollectionConfig) -> Json

    Serializes the collection configuration for a create request.

    This is the body create_collection sends, with get_or_create set to false.

    Config

    pub(all) struct Config {
    endpoint : String
    tenant : String
    database : String
    headers : Map[String, String]
    timeout_ms : Int
    } derive(Eq,
    Debug
    )

    Configuration for talking to a Chroma server.

    The client speaks the v2 API, so endpoint is normally http://localhost:8000 and the tenant/database are separate fields.

    Config::equal

    fn Config::equal(Config, Config) -> Bool

    Config::new

    fn Config::new(endpoint? : String, tenant? : String, database? : String, headers? : Map[String, String], timeout_ms? : Int) -> Config

    Builds a Config with the Chroma defaults: localhost:8000, tenant default_tenant, database default_database.

    Config::not_equal

    fn Config::not_equal(x : Config, y : Config) -> Bool

    Config::to_repr

    Config::with_basic_auth

    fn Config::with_basic_auth(self : Config, username : String, password : String) -> Config

    Returns a copy of the configuration with HTTP Basic authentication.

    Config::with_header

    fn Config::with_header(self : Config, name : String, value : String) -> Config

    Returns a copy of the configuration with one extra header set.

    Config::with_headers

    fn Config::with_headers(self : Config, headers : Map[String, String]) -> Config

    Returns a copy of the configuration with a merged header map, leaving self untouched.

    Config::with_token

    fn Config::with_token(self : Config, token : String) -> Config

    Returns a copy of the configuration with a Bearer token in Authorization.

    Filter

    pub(all) struct Filter {
    where_clause : Json?
    where_document_clause : Json?
    ids : Array[String]
    limit : Int?
    offset : Int?
    } derive(Eq,
    Debug
    )

    Where-clause filters for get, update and delete.

    These mirror Chroma's where / where_document JSON filters.

    Filter::equal

    fn Filter::equal(Filter, Filter) -> Bool

    Filter::new

    fn Filter::new(where_clause? : Json?, where_document_clause? : Json?, ids? : Array[String], limit? : Int?, offset? : Int?) -> Filter

    Builds an empty filter that matches everything.

    Filter::not_equal

    fn Filter::not_equal(x : Filter, y : Filter) -> Bool

    Filter::to_repr

    Filter::with_limit

    fn Filter::with_limit(self : Filter, limit : Int) -> Filter

    Adds a limit to the filter.

    Filter::with_metadata

    fn Filter::with_metadata(self : Filter, key : String, value : MetadataValue) -> Filter

    Adds a metadata equality condition to the filter.

    Include

    pub(all) struct Include {
    documents : Bool
    embeddings : Bool
    metadatas : Bool
    distances : Bool
    } derive(Eq,
    Debug
    )

    How a query should return embeddings, documents and metadata.

    Chroma only honours include on query/get endpoints, and uploading embeddings is usually what you want to skip on large collections.

    Include::all

    fn Include::all() -> Include

    Every field, including embeddings.

    Include::default

    fn Include::default() -> Include

    Default include set: documents, metadata and distances, no vectors.

    Include::equal

    fn Include::equal(Include, Include) -> Bool

    Include::ids_only

    fn Include::ids_only() -> Include

    Only ids.

    Include::not_equal

    fn Include::not_equal(x : Include, y : Include) -> Bool

    Include::to_repr

    MemoryTransport

    pub(all) struct MemoryTransport {
    responses : Array[Response]
    observed : Array[Request]
    cursor :
    Ref
    [Int]
    } derive(
    Debug
    )

    A scripted transport for tests: records every request and replays the queued responses in order.

    MemoryTransport::last_request

    fn MemoryTransport::last_request(self : MemoryTransport) -> Request?

    Returns the last observed request, if any.

    MemoryTransport::new

    fn MemoryTransport::new(responses? : Array[Response]) -> MemoryTransport

    Creates a MemoryTransport that returns the given responses in order.

    MemoryTransport::observed

    fn MemoryTransport::observed(self : MemoryTransport) -> Array[Request]

    Returns the requests this transport has observed, in send order.

    MemoryTransport::send

    fn MemoryTransport::send(self : MemoryTransport, request : Request) -> Response raise

    MetadataValue

    pub(all) enum MetadataValue {
    MString(String)
    MInt(Int)
    MFloat(Double)
    MBool(Bool)
    MNull
    } derive(Eq,
    Debug
    )

    Metadata attached to a collection or a record.

    Chroma only accepts scalar metadata values, which MetadataValue models directly.

    MetadataValue::equal

    MetadataValue::from_json

    fn MetadataValue::from_json(value : Json) -> MetadataValue?

    Converts a JSON scalar into a MetadataValue, returning None for arrays and objects since Chroma rejects them as metadata.

    MetadataValue::not_equal

    fn MetadataValue::not_equal(x : MetadataValue, y : MetadataValue) -> Bool

    MetadataValue::to_json

    fn MetadataValue::to_json(self : MetadataValue) -> Json

    Converts a MetadataValue into the JSON representation Chroma expects.

    Method

    pub(all) enum Method {
    Get
    Post
    Put
    Delete
    Patch
    } derive(Eq,
    Debug
    )

    HTTP verb used by the Chroma v2 API.

    Method::equal

    fn Method::equal(Method, Method) -> Bool

    Method::not_equal

    fn Method::not_equal(x : Method, y : Method) -> Bool

    Method::output

    fn Method::output(self : Method, logger : &Logger) -> Unit

    Method::to_repr

    Method::to_string

    fn Method::to_string(self : Method) -> String

    QueryResult

    pub(all) struct QueryResult {
    ids : Array[String]
    documents : Array[String?]
    embeddings : Array[Array[Double]?]
    metadatas : Array[Array[(String, MetadataValue)]]
    distances : Array[Double?]
    } derive(Eq,
    Debug
    )

    A row returned by get or query.

    QueryResult::equal

    fn QueryResult::equal(QueryResult, QueryResult) -> Bool

    QueryResult::from_json

    fn QueryResult::from_json(json : Json) -> QueryResult raise ChromaError

    Reads a QueryResult from a get response.

    ids is the one field every result must carry, so its absence is a decode error rather than an empty result: an empty ids is a valid answer, a missing one means the response is not a result set. The remaining fields depend on include, so they stay optional.

    QueryResult::from_query_json

    fn QueryResult::from_query_json(json : Json) -> QueryResult

    Reads a query response, where each field is an array with one entry per query embedding. Returns the result for the first embedding.

    Unlike from_query_json, the nested ids may legitimately be missing when the query matched nothing, so an absent field still means "no results" here.

    QueryResult::not_equal

    fn QueryResult::not_equal(x : QueryResult, y : QueryResult) -> Bool

    Record

    pub(all) struct Record {
    id : String
    document : String?
    embedding : Array[Double]?
    metadata : Array[(String, MetadataValue)]
    } derive(Eq,
    Debug
    )

    A record stored in a collection.

    embedding is None for a record that carries only a document. Chroma needs a vector for every record, so a batch with a missing embedding is rejected locally instead of by the server (see validate_records).

    Record::equal

    fn Record::equal(Record, Record) -> Bool

    Record::not_equal

    fn Record::not_equal(x : Record, y : Record) -> Bool

    Record::of_document

    fn Record::of_document(id : String, document : String, metadata? : Array[(String, MetadataValue)]) -> Record

    Builds a record that carries a document and no embedding.

    Chroma 1.x has no server-side embedding, so a record built this way can only be stored once an embedding is attached via Record::with_embedding.

    Record::of_embedding

    fn Record::of_embedding(id : String, embedding : Array[Double], metadata? : Array[(String, MetadataValue)]) -> Record

    Builds a record with a caller-supplied embedding.

    Record::to_repr

    Record::with_document

    fn Record::with_document(self : Record, document : String) -> Record

    Returns a copy of the record carrying document as well.

    Pairs with Record::of_embedding when the vector is known first and the text it stands for second.

    Record::with_embedding

    fn Record::with_embedding(self : Record, embedding : Array[Double]) -> Record

    Returns a copy of the record with embedding attached.

    Use it to add a vector to a record built by Record::of_document; a record with explicit coordinates is built with Record::of_embedding.

    Record::with_metadata

    fn Record::with_metadata(self : Record, metadata : Array[(String, MetadataValue)]) -> Record

    Returns a copy of the record carrying metadata instead of its own.

    Request

    pub(all) struct Request {
    verb : Method
    url : String
    headers : Map[String, String]
    body : String?
    } derive(Eq,
    Debug
    )

    A fully-built HTTP request, ready to hand to a transport.

    Request::equal

    fn Request::equal(Request, Request) -> Bool

    Request::not_equal

    fn Request::not_equal(x : Request, y : Request) -> Bool

    Request::to_repr

    Response

    pub(all) struct Response {
    status : Int
    headers : Map[String, String]
    body : String
    } derive(Eq,
    Debug
    )

    The response a transport produces.

    Response::equal

    fn Response::equal(Response, Response) -> Bool

    Response::json

    fn Response::json(status : Int, body : String, headers? : Map[String, String]) -> Response

    Convenience constructor for a scripted JSON response.

    Response::not_equal

    fn Response::not_equal(x : Response, y : Response) -> Bool

    Response::to_repr

    RetryPolicy

    pub(all) struct RetryPolicy {
    enabled : Bool
    max_attempts : Int
    initial_delay_ms : Int
    max_delay_ms : Int
    backoff_factor : Double
    } derive(Eq,
    Debug
    )

    Retry policy for RetryTransport.

    Retrying is never implicit: nothing is retried until a RetryTransport is built with enabled: true, so a plain client keeps the "one call, one request" behaviour.

    RetryPolicy::delay_ms

    fn RetryPolicy::delay_ms(self : RetryPolicy, attempt : Int) -> Int

    The delay to wait before attempt attempt (0-based) of the backoff sequence, capped at max_delay_ms.

    RetryPolicy::equal

    fn RetryPolicy::equal(RetryPolicy, RetryPolicy) -> Bool

    RetryPolicy::new

    fn RetryPolicy::new(enabled? : Bool, max_attempts? : Int, initial_delay_ms? : Int, max_delay_ms? : Int, backoff_factor? : Double) -> RetryPolicy

    Builds a retry policy. enabled defaults to false, so the caller has to opt in to the retry behaviour.

    RetryPolicy::not_equal

    fn RetryPolicy::not_equal(x : RetryPolicy, y : RetryPolicy) -> Bool

    RetryTransport

    pub(all) struct RetryTransport[T] {
    inner : T
    policy : RetryPolicy
    on_retry : (Int, Int, ChromaError) -> Unit?
    }

    A transport that re-sends requests the server answered with 429 or 5xx.

    It waits between attempts only on the caller's behalf: the library never sleeps, because blocking inside a transport is exactly the implicit behaviour the retry policy is meant to avoid. Instead each retry is announced as on_retry(attempt, delay_ms, error) and the delay is stamped on the request as x-chroma-retry-delay-ms, the same contract TimeoutTransport uses for deadlines. A transport that cannot wait and has no hook still sees the retried requests; it just sends them back to back.

    RetryTransport::new

    fn[T] RetryTransport::new(inner : T, policy? : RetryPolicy) -> RetryTransport[T]

    Wraps inner with policy.

    RetryTransport::on_retry

    fn[T] RetryTransport::on_retry(self : RetryTransport[T], handler : (Int, Int, ChromaError) -> Unit) -> RetryTransport[T]

    Returns a copy that reports every retry before it is attempted.

    The callback is where a real transport blocks, logs or reports metrics; a browser build could sleep with setTimeout there.

    RetryTransport::policy

    fn[T] RetryTransport::policy(self : RetryTransport[T]) -> RetryPolicy

    Errors raised by ChromaClient::send keep their original shape, so the retry loop only has to decide on HTTP statuses. A failure to send at all is not retried: under a transport that raises instead of returning a status there is no way to tell a dropped connection from a bad URL.

    RetryTransport::send

    fn[T : Transport] RetryTransport::send(self : RetryTransport[T], request : Request) -> Response raise

    RetryTransport::with_policy

    fn[T] RetryTransport::with_policy(self : RetryTransport[T], policy : RetryPolicy) -> RetryTransport[T]

    Returns a copy with a different policy.

    TimeoutTransport

    pub(all) struct TimeoutTransport[T] {
    inner : T
    timeout_ms : Int
    }

    Wraps a transport so that every request carries a deadline.

    The library never performs I/O itself, so it cannot enforce a timeout on its own: cancelling a socket read is a property of the HTTP stack, not of the request. This wrapper makes the promise explicit instead. It stamps x-chroma-timeout-ms on each request and hands it to the inner transport, so a real transport can honour the value; a transport that ignores the header still works, it just loses the guarantee.

    let client = ChromaClient::new(TimeoutTransport::new(http_stack, 5000))

    TimeoutTransport::from_config

    fn[T] TimeoutTransport::from_config(inner : T, config : Config) -> TimeoutTransport[T]

    Builds a timeout transport from a client's own configuration, so Config::timeout_ms gets a consumer.

    TimeoutTransport::new

    fn[T] TimeoutTransport::new(inner : T, timeout_ms : Int) -> TimeoutTransport[T]

    Wraps inner, limiting each request to timeout_ms milliseconds.

    A non-positive value means "no deadline", in which case only the header stamping is skipped and requests pass through untouched.

    TimeoutTransport::send

    fn[T : Transport] TimeoutTransport::send(self : TimeoutTransport[T], request : Request) -> Response raise

    MAX_RESPONSE_BYTES

    let MAX_RESPONSE_BYTES : Int

    The largest response body the client will decode.

    The client reads a whole body into one String, and MoonBit strings are UTF-8, so the limit is stated in bytes: 32 MiB is far above any realistic result set (a full Include::all() page of 1024-dimension vectors is a few megabytes) yet small enough that a runaway response cannot exhaust memory before the caller sees an error.

    Truncating is not an option, because a half-parsed result set would look like data loss. A response past the limit fails loudly instead, and the caller can narrow include, lower n_results or page with limit.

    PEEK_LIMIT

    let PEEK_LIMIT : Int

    The largest limit peek will send.

    RETRY_DELAY_HEADER

    let RETRY_DELAY_HEADER : String

    The header carrying the delay a real transport should wait before re-sending.

    TIMEOUT_HEADER

    let TIMEOUT_HEADER : String

    The header a transport reads to learn its deadline.

    api_error

    fn api_error(status : Int, body : String) -> ChromaError

    Builds the error to raise for a non-2xx response.

    The body is probed for the Chroma error envelope; only when it carries neither error nor message does the error stay a raw Status.

    append_query

    fn append_query(url : String, params : Array[(String, String)]) -> String

    Appends key=value pairs to a URL, percent-encoding both sides.

    debug_collection_config

    fn debug_collection_config(config : CollectionConfig) -> Json

    Serializes a collection configuration, useful for logging and for building custom requests.

    Same value as CollectionConfig::to_json; the name is kept because the logging use case reads better with it.

    embedding_from_json

    fn embedding_from_json(json : Json) -> Array[Double] raise ChromaError

    Reads a JSON array of numbers into an embedding.

    embedding_to_json

    fn embedding_to_json(embedding : Array[Double]) -> Json

    Converts an embedding to a JSON array of numbers.

    encode_path_segment

    fn encode_path_segment(segment : String) -> String

    Percent-encodes a single path segment.

    Collection names may contain spaces and other characters that are not legal in a URL path, so every dynamic segment goes through here.

    encode_query_component

    fn encode_query_component(value : String) -> String

    Percent-encodes a query component. Unlike a path segment a space may be written as + here.

    join_url

    fn join_url(base : String, path : String) -> String

    Joins a base URL and a path with exactly one separating slash, so join_url("http://localhost:8000/", "/api/v2") is http://localhost:8000/api/v2.

    metadata_from_json

    fn metadata_from_json(json : Json) -> Array[(String, MetadataValue)]

    Reads a JSON object into a Metadata.

    Values Chroma cannot store (arrays and objects) are skipped rather than failing the whole response.

    metadata_to_json

    fn metadata_to_json(metadata : Array[(String, MetadataValue)]) -> Json

    Converts a Metadata list into a JSON object.

    records_to_columns

    fn records_to_columns(records : Array[Record]) -> Json

    Serializes a batch of records into the columnar body Chroma expects.

    Chroma does not take records one by one: the add/upsert/update models are parallel arrays (ids, embeddings, documents, metadatas) that must be the same length and aligned by index. A column is emitted only when it carries data, and a null marks a record that has no value for it — that position-preserving null is what keeps the arrays aligned.

    embeddings is the exception: Chroma requires the field and rejects a batch where it is missing, null or empty, so a record without coordinates has no representation here. validate_records refuses such a batch before it is serialized.

    validate_limit_offset

    fn validate_limit_offset(limit : Int?, offset : Int?) -> Unit raise ChromaError

    Validates a pagination window.

    Chroma rejects a request where offset is not smaller than limit, because the two are meant to walk a result set together. Catching it here turns a server round-trip and an opaque 400 into a local, actionable error.

    validate_n_results

    fn validate_n_results(n_results : Int) -> Unit raise ChromaError

    Rejects an n_results that Chroma cannot answer.

    validate_query_inputs

    fn validate_query_inputs(label : String, inputs : Array[Json]) -> Unit raise ChromaError

    Rejects a query whose inputs would be refused server-side.

    label names the field in the error message so a caller passing both texts and vectors can tell which one was empty.

    validate_records

    fn validate_records(records : Array[Record], require_embedding? : Bool) -> Unit raise ChromaError

    Rejects record batches Chroma would refuse, before a request is built.

    Chroma answers 400 for an empty batch and for a blank id, and its add/upsert models make embeddings mandatory, so a batch whose records have no vectors is refused too. All of these are local mistakes with local fixes, so they are caught here instead of after a round-trip.

    require_embedding is false for update, where omitting a field is how a caller says "change only what I passed".