///|
/// Lists the collections in the configured tenant and database.
///
/// `limit` and `offset` page the listing, the same way chroma-go's
/// `ListWithLimit` / `ListWithOffset` do. Both default to "not sent", which
/// returns everything.
pub fn[T : Transport] ChromaClient::list_collections(
self : ChromaClient[T],
limit~ : Int32 = 0,
offset~ : Int32 = 0,
) -> Array[Collection] raise ChromaError {
let response = self.send_json(Get, self.list_collections_path(limit, offset))
let items = match response {
Json::Array(items) => items
_ => raise ChromaError::Decode("expected an array of collections")
}
let out : Array[Collection] = []
for item in items {
out.push(Collection::from_json(item))
}
out
}
///|
/// Counts the collections in the configured tenant and database.
pub fn[T : Transport] ChromaClient::count_collections(
self : ChromaClient[T],
) -> Int32 raise ChromaError {
let response = self.send_json(
Get,
self.database_prefix() + "/collections_count",
)
decode_count(response, "collections_count")
}
///|
/// 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.
pub fn[T : Transport] ChromaClient::get_or_create_collection(
self : ChromaClient[T],
name : String,
config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
let path = self.database_prefix() + "/collections"
Collection::from_json(
self.send_json(
Post,
path,
body=create_body(name, config, get_or_create=true),
),
)
}
///|
/// 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.
pub fn[T : Transport] ChromaClient::create_collection(
self : ChromaClient[T],
name : String,
config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
let path = self.database_prefix() + "/collections"
Collection::from_json(
self.send_json(
Post,
path,
body=create_body(name, config, get_or_create=false),
),
)
}
///|
/// Fetches an existing collection by name, returning `None` on 404.
pub fn[T : Transport] ChromaClient::get_collection(
self : ChromaClient[T],
name : String,
) -> Collection? raise ChromaError {
let path = self.database_prefix() +
"/collections/" +
encode_path_segment(name)
let response = self.send_json(Get, path) catch {
// The server may report the miss as a bare status or as a parsed
// `Api` error; both mean "no such collection".
error =>
if error.status_code() == Some(404) {
return None
} else {
raise error
}
}
Some(Collection::from_json(response))
}
///|
/// Deletes a collection by name.
pub fn[T : Transport] ChromaClient::delete_collection(
self : ChromaClient[T],
name : String,
) -> Unit raise ChromaError {
let path = self.database_prefix() +
"/collections/" +
encode_path_segment(name)
ignore(self.send_json(Delete, path))
}
///|
/// Updates a collection's name and/or metadata.
///
/// `new_name` of `None` keeps the current name, and `metadata` of `None`
/// keeps the current metadata; `Some([])` is how a caller clears it. `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`).
pub fn[T : Transport] ChromaClient::modify_collection(
self : ChromaClient[T],
name : String,
new_name~ : String? = None,
metadata~ : Metadata? = None,
) -> Unit raise ChromaError {
let body = modify_body(new_name, metadata)
let id = self.resolve_collection_id(name)
let path = self.database_prefix() + "/collections/" + encode_path_segment(id)
ignore(self.send_json(Put, path, body=Json::object(body)))
}
///|
/// Builds the `UpdateCollection` body, including only the fields given.
///
/// Chroma's model names the metadata field `new_metadata`;
/// sending `metadata` is silently ignored by the server.
fn modify_body(new_name : String?, metadata : Metadata?) -> Map[String, Json] {
let body : Map[String, Json] = Map([])
match new_name {
Some(value) => body["new_name"] = Json::string(value)
None => ()
}
match metadata {
Some(value) => body["new_metadata"] = metadata_to_json(value)
None => ()
}
body
}
///|
/// Resolves an id-or-name argument to the collection's id.
///
/// `PUT /collections/{id}` rejects anything that is not a UUIDv4 with a 400
/// ("Collection ID is not a valid UUIDv4"), unlike the `GET`/`DELETE` routes
/// below the same path, which also accept a name. Callers pass whichever they
/// have, so a value that already looks like an id is used as-is and a name is
/// looked up through `get_collection`.
fn[T : Transport] ChromaClient::resolve_collection_id(
self : ChromaClient[T],
name_or_id : String,
) -> String raise ChromaError {
if is_uuid_v4(name_or_id) {
return name_or_id
}
match self.get_collection(name_or_id) {
Some(collection) => collection.id
None =>
raise ChromaError::InvalidArgument("no such collection: " + name_or_id)
}
}
///|
/// Whether `value` is a canonical UUIDv4 string.
///
/// Only the shape matters: a name can never be 36 characters of hex and
/// hyphens in the 8-4-4-4-12 layout, so this tells the two apart without a
/// round trip.
fn is_uuid_v4(value : String) -> Bool {
if value.length() != 36 {
return false
}
let chars = value.to_array()
for i, c in chars {
let dash = i == 8 || i == 13 || i == 18 || i == 23
if dash {
if c != '-' {
return false
}
} else if !((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F')) {
return false
}
}
// The version nibble of a v4 UUID is `4`; the server checks it.
chars[14] == '4'
}
///|
/// Counts the records in a collection.
pub fn[T : Transport] ChromaClient::count(
self : ChromaClient[T],
collection_id : String,
) -> Int32 raise ChromaError {
let path = self.collection_path(collection_id) + "/count"
decode_count(self.send_json(Get, path), "collections/{id}/count")
}
///|
/// Returns a small sample of the records in a collection.
///
/// `limit` defaults to 10 and is clamped to `PEEK_LIMIT` (1..=100) on the way
/// out, so a stray large value cannot turn a preview into a full scan of the
/// collection. `include_fields` defaults to `Include::get_default()`
/// (documents and metadata), the same default as `get`: `peek` posts to
/// `POST /get`, and Chroma rejects that endpoint when it carries `distances`.
///
/// It is a `get` with one page and no filter: `POST /get` gets a `limit` and
/// an `include`, nothing else, so there is no `where` clause to pass. Use
/// `get(collection_id, filter~)` when the sample has to be filtered.
pub fn[T : Transport] ChromaClient::peek(
self : ChromaClient[T],
collection_id : String,
limit~ : Int32 = 10,
include_fields~ : Include = Include::get_default(),
) -> QueryResult raise ChromaError {
let body = peek_body(limit, include_fields)
let path = self.collection_path(collection_id) + "/get"
QueryResult::from_json(self.send_json(Post, path, body~))
}
///|
/// Builds the `POST /get` body `peek` sends: a clamped limit plus `include`.
///
/// `peek` is a `get` with one page and no filter, so the body has the same
/// shape `get` builds — it just always carries a limit.
fn peek_body(limit : Int32, include_fields : Include) -> Json {
let body : Map[String, Json] = Map([])
body["limit"] = Json::number(clamp_peek_limit(limit).to_double())
body["include"] = include_fields.to_json()
Json::object(body)
}
///|
/// The largest `limit` `peek` will send.
pub const PEEK_LIMIT : Int32 = 100
///|
/// Creates the database this client is configured for.
///
/// Chroma keeps tenants and databases as ordinary resources: a tenant is
/// created on demand but a database is not, so writing into a database that
/// does not exist answers 400. This is the one call a caller needs to prepare
/// a namespace; a 409 means it already exists.
///
/// The async twin is `create_database_async`.
pub fn[T : Transport] ChromaClient::create_database(
self : ChromaClient[T],
) -> Unit raise ChromaError {
ignore(
self.send_json(
Post,
self.create_database_path(),
body=self.create_database_body(),
),
)
}
///|
/// The path `POST /tenants/{tenant}/databases` lives at.
///
/// A database is created under a tenant, not under a database, so this is the
/// one resource path that does not start with `database_prefix`. The async
/// twin builds it the same way, through this function.
fn[T] ChromaClient::create_database_path(self : ChromaClient[T]) -> String {
"tenants/" + encode_path_segment(self.config.tenant) + "/databases"
}
///|
/// The body of a `create_database` request: the database's name.
///
/// Chroma reads `name` here, not `database`, and the name comes from the
/// client's own config — there is no argument, because a client can only
/// prepare the namespace it is pointed at.
fn[T] ChromaClient::create_database_body(self : ChromaClient[T]) -> Json {
Json::object({ "name": Json::string(self.config.database) })
}
///|
/// Clamps a peek limit into `1..=PEEK_LIMIT`.
fn clamp_peek_limit(limit : Int32) -> Int32 {
if limit < 1 {
1
} else if limit > PEEK_LIMIT {
PEEK_LIMIT
} else {
limit
}
}
///|
/// Builds the collections listing path, including the optional paging query.
fn[T] ChromaClient::list_collections_path(
self : ChromaClient[T],
limit : Int32,
offset : Int32,
) -> String {
let path = self.database_prefix() + "/collections"
let params : Array[(String, String)] = []
if limit > 0 {
params.push(("limit", limit.to_string()))
}
if offset > 0 {
params.push(("offset", offset.to_string()))
}
append_query(path, params)
}