nelcota-client
Async Rust SDK for Nelcota: REST tables/views and RPC, auth sessions and OAuth, and file storage under PostgreSQL RLS. For native applications using Tokio.
[dependencies]
nelcota-client = "0.1.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Requires Rust 1.88+ (checked in CI) and Nelcota server 0.3.0 or later.
| SDK | Server baseline | Support |
|---|---|---|
| 0.1.x | Server v0.3.0 or later |
Full HTTP/Postgres contract suite, OAuth, update_user and generated Rust models |
| 0.1.x | Server v0.2.0 |
Not supported: lacks the OAuth routes, PUT /auth/v1/user, the JSON error contract and the Rust generator |
use nelcota_client::{Client, rest::Order};
use serde::Deserialize;
#[derive(Deserialize)]
struct Note { id: i64, body: String }
# async fn example() -> nelcota_client::Result<()> {
let client = Client::builder("https://api.example.com").build()?;
client.auth().sign_in_with_password("[email protected]", "password").await?;
let response = client.from("notes").select("id,body")
.order("id", Order::descending()).range(0, 19).count_exact()
.execute::<Vec<Note>>().await?;
for note in response.data { println!("{}: {}", note.id, note.body); }
# Ok(()) }Version scope
This reference describes current source. The published package is 0.1.2; Unreleased refresh-margin/finite-input fixes and expanded lifecycle checks require a new SDK build/package. Installing a server release does not update clients. Use registry installation for released behavior and a path/archive dependency for development.
REST
Builders are local and consume self; clone a base query to reuse it. Only
execute sends HTTP. Results are Result<Response<T>, Error>; response metadata
includes HTTP status, count and headers. Decode into your own Deserialize
structs or serde_json::Value. Column names and select expressions are checked
by the server against its current catalog; these strings are not compile-time
schema validation.
# async fn example(client: nelcota_client::Client) -> nelcota_client::Result<()> {
use serde_json::{Value, json};
use nelcota_client::rest::{Condition, IsValue, UpsertOptions};
let note = client.from("notes").insert(&json!({"body":"hello"}))
.select("id,body").single().execute::<Value>().await?;
client.from("notes").update(&json!({"body":"edited"}))
.eq("id", ¬e.data["id"]).execute::<()>().await?;
client.from("notes").delete().eq("id", ¬e.data["id"])
.execute::<()>().await?;
client.from("products").upsert(&json!({"slug":"pen","name":"Pen"}),
UpsertOptions { on_conflict:vec!["slug".into()], ..Default::default() })
.execute::<()>().await?;
client.from("orders").select("id,customer:customers(name),items(product,qty)")
.gte("items.qty", 2)
.or([Condition::eq("status", "paid"),
Condition::all([Condition::gt("total", 100), !Condition::is("archived", IsValue::True)])])
.execute::<Vec<Value>>().await?;
let total = client.rpc("cart_total", &json!({"cart_id":7}))
.execute::<Value>().await?;
# Ok(()) }Filters: eq, neq, gt, gte, lt, lte, like, ilike, in_values,
not_in, is/is_null, not_is, not, matches, and, or.
Conditions are constructed, never assembled from untrusted syntax strings.
Embedded filters use paths such as items.qty; order_on, limit_on,
range_on, and_on, or_on target embedded rows. order calls accumulate.
Writes return an empty body by default (execute::<()>()); call select for
representation. single requires exactly one row; maybe_single returns null
for zero rows (decode to Option<T>). Use head().count_exact().execute::<()>()
for count-only reads. Update/delete require a parent filter before any HTTP is
sent. Exact counts scan all matching rows; only request them when needed.
Each read returns at most NELCOTA_MAX_ROWS rows (1000 by default), even
without limit. Page with range(start, end) or limit + offset, ordered
by a unique column, until a page comes back empty; count_exact() gives the
total when you need it.
Auth
# async fn example(client: nelcota_client::Client) -> nelcota_client::Result<()> {
let signup = client.auth().sign_up("[email protected]", "strong-password-123", None).await?;
// signup.session is None while email confirmation is pending.
let session = client.auth().get_session().await?;
let user = client.auth().get_user().await?;
// Merged into user_metadata; a null value removes the key.
client.auth().update_user(nelcota_client::auth::UserUpdate {
data: Some(serde_json::json!({ "plan": "pro" })),
..Default::default()
}).await?;
let mut events = client.auth().subscribe();
let background = client.auth().start_auto_refresh(); // opt-in; retain this handle
client.auth().request_password_reset("[email protected]").await?;
client.auth().send_magic_link("[email protected]").await?;
client.auth().resend_confirmation("[email protected]").await?;
client.auth().sign_out().await?;
drop(background);
# Ok(()) }Session storage defaults to memory. For a CLI or desktop app,
auth::FileStorage::new(path) keeps the session in a JSON file, written
atomically and readable by its owner only (0600 on Unix). Implement
auth::SessionStorage for a keychain, encrypted file or per-request cookie
store.
# fn example() -> nelcota_client::Result<()> {
use std::sync::Arc;
use nelcota_client::{Client, auth::FileStorage};
let client = Client::builder("https://api.example.com")
.session_storage(Arc::new(FileStorage::new("/home/ana/.config/notes/session.json")))
.build()?;
# Ok(()) }Clones share one session, lifecycle lock and refresh result. Independent clients/processes sharing a persistent store need external coordination: the store interface alone does not serialize refresh. A spent refresh token terminates its server session.
Refresh runs before expiry when a request needs a token. Opt-in background refresh stops when its handle is dropped. Both paths cap the configured refresh margin at half the token lifetime, avoiding repeated rotation of fresh short-lived tokens. Rotation continues if a waiter is cancelled/dropped, so a consumed refresh token can still be replaced locally. A transient failure keeps the previous identity; an invalid grant clears the session and returns the failure to the initiating request. Logout waits for rotation and clears locally even if server revocation fails. Existing access tokens may remain valid until expiry.
In a backend, scope a caller token to a clone; this does not alter the shared session or refresh the supplied token:
# fn example(client: nelcota_client::Client, caller_token: String) {
let caller = client.with_access_token(caller_token);
let visitor = client.anonymous();
# }OAuth uses PKCE S256 with Google/GitHub. begin_oauth returns PkceFlow { url, verifier }; keep it private, open its URL in your application's browser flow,
and consume it with handle_redirect(&mut callback_url, flow) or exchange the
code/verifier explicitly. The SDK does not open a browser or create a callback
server. redirect_to must be in NELCOTA_OAUTH_REDIRECT_URLS.
Email links carry #type=...&token=.... EmailLink::parse(&mut url) extracts
the token and clears the fragment. Pass signup/magic links to
verify_email_link; use reset_password(token, password) for recovery.
Session, flow and link Debug implementations omit secret tokens.
Storage
# async fn example(client: nelcota_client::Client, user_id: String) -> nelcota_client::Result<()> {
use nelcota_client::storage::{UploadOptions, OpenOptions, ListOptions};
let files = client.storage().from("avatars")?;
let name = format!("{user_id}/me.txt");
files.upload(&name, "hello", UploadOptions::default()).await?; // text/plain, from .txt
files.upload("logo", "<svg/>", UploadOptions {
content_type: Some("image/svg+xml".into()), upsert: true,
}).await?;
let bytes = files.download(&name).await?;
let response = files.open(&name, OpenOptions { range:Some((0,Some(3))), ..Default::default() }).await?;
let listing = files.list(ListOptions { prefix:format!("{user_id}/"), ..Default::default() }).await?;
let signed_url = files.create_signed_url(&name, 3600).await?;
let public_url = files.public_url(&name)?;
files.remove(&name).await?;
# Ok(()) }Without an explicit content_type, uploads guess it from the name's extension
(json, csv, txt, html, css, js, svg, png, jpg, gif, webp,
pdf, mp4 and other common types; see storage::guess_content_type) and
fall back to application/octet-stream. upload_reader streams a Tokio reader. open returns a streaming
reqwest::Response; handle 206, 304, ETag and stream failures yourself after it
returns. download buffers the file and reports body failures in its Result.
Uploads/downloads default to no deadline; scope a RequestOptions timeout when
needed. S3 redirects are followed; bearer tokens are stripped across origins.
Object names are NFC-normalized and encoded per segment. Empty/dot segments,
backslashes and control characters are rejected.
Buckets support list/get/create/update/delete, usually with service_role.
update_bucket replaces all settings, so it takes a BucketUpdate, which has
no Default: every field is explicit, and None clears a limit on purpose.
Start from the current bucket to change one setting:
# async fn example(client: nelcota_client::Client) -> nelcota_client::Result<()> {
use nelcota_client::storage::BucketUpdate;
let current = client.storage().get_bucket("avatars").await?;
client.storage().update_bucket("avatars", BucketUpdate { public: true, ..current.into() }).await?;
# Ok(()) }Authorization belongs to server RLS; a service_role token bypasses it and
belongs only in a trusted application.
Rust schema types
nelcota types --lang rust -o src/database.rsGenerated modules expose schema::table::{Row, Insert, Update, NAME, PRIMARY_KEY} and schema::rpc::function::{Args, Returns, NAME}. Enum columns
have serde-renamed enums. Generated/identity-always columns are omitted from
write models; views have write models except materialized views. Projections
and embeds need structs matching their selected shape.
Field::Omit skips a write field, keeping its default/current value;
Field::Value(None) sends explicit null for nullable columns. UUID/date/time
and unknown scalar types use strings. Bigint uses i64, numeric uses
serde_json::Number with arbitrary precision, and arrays allow null elements.
RPC return aliases allow null; optional arguments distinguish omission from
explicit null. Types are generated from the configured exposed schema, without
requiring a DB connection when compiling consumer applications.
Errors, deadlines and retries
Error::code(), status() and retry_after() preserve server facts.
Other variants distinguish invalid input, network, timeout, cancellation,
invalid response, cardinality and session storage failures. No panic is used
for caller configuration or HTTP failures. Signed request URLs are omitted
from transport error text. A response without a JSON body (a head() count, a
proxy page) gets unauthorized, forbidden, not_found, rate_limited,
unavailable or http_<status> as its code.
When the server reports them, sqlstate(), details(), hint() and
constraint() expose what Postgres said about a db_error; older servers send
none. Helpers classify common cases: is_not_found(), is_conflict(),
is_unique_violation(), is_rate_limited(), is_unauthorized(),
is_forbidden() and is_retryable().
# async fn example(client: nelcota_client::Client) -> nelcota_client::Result<()> {
use serde_json::json;
match client.from("notes").insert(&json!({"slug":"a"})).execute::<()>().await {
Err(error) if error.is_unique_violation() => {
println!("taken ({:?})", error.constraint());
}
other => { other?; }
}
# Ok(()) }Only GET/HEAD retry network/body-read failures and 429/503, default two retries.
The backoff has jitter and respects Retry-After (seconds or HTTP date); a delay
over 30 seconds returns immediately. Mutations, auth exchange, RPC and POST
storage listings never retry automatically. Timeouts cover each attempt and
buffered body; refresh and backoff are separate. Drop a request future or use
CancellationToken to cancel; an already accepted server write cannot be undone
by cancelling the client. Raw stream lifetime is managed by reqwest/caller.
Client::builder(url).header("x-app", "notes") adds a header to every request;
build refuses invalid headers and Authorization, which comes from
access_token or the session. Custom http_client instances must disable
their own auth/mutation retries.
The default client uses explicit rustls/ring and Mozilla roots without a global
crypto-provider initialization. It can be reused across tasks.
Start with the quickstart
(local server, migration, generated types). The examples in examples/ use
the shared examples/notes.sql
schema. Empty results, 401/403 or a 1000-row cap? See
troubleshooting.
Portuguese guide: Começando com Rust.
Contract: Rust SDK contract. Release: releasing.md.