@ikeboy003/cloudrest-client
v0.6.1
Published
Typed query builder + realtime client for cloudrest. PostgREST-grammar compatible.
Readme
@ikeboy003/cloudrest-client
Typed query builder for cloudrest / postgrest-rs.
PostgREST-grammar compatible, zero deps, works anywhere fetch exists.
import { createClient } from "@ikeboy003/cloudrest-client";
import type { paths } from "./schema"; // openapi-typescript output (optional)
const db = createClient<paths>({
baseUrl: "https://api.example.com",
getToken: () => localStorage.getItem("jwt"),
});Reads
const books = await db.from("books")
.select("id,title,author(*)")
.eq("published", true)
.gte("year", 2000)
.order("year", { ascending: false })
.limit(20);
const one = await db.from("books").select("*").eq("id", 1).single();
// Paged: rows + total via Content-Range.
const { rows, count } = await db.from("books")
.select("*").range(0, 19).count("estimated").withCount();Every PostgREST operator is here: eq neq gt gte lt lte like ilike match imatch
in is isDistinct fts plfts phfts wfts cs cd ov sl sr nxr nxl adj not notIs filter,
plus nestable and()/or() via the cond.* helpers.
viaQuery() — filters in the body
When a big in.(...) list would blow past the ~8 KB URL ceiling, send the read
as an RFC-10008 QUERY: identical result, filters travel in the body.
await db.from("events").select("*").in("id", tenThousandIds).viaQuery();Writes
await db.insert("books", { title: "x" }).returning();
await db.insert("books", rows).onConflict(["isbn"]).returning(); // upsert
await db.insert("books", rows).onConflict("isbn", { ignoreDuplicates: true });
await db.update("books", { sold: true }).eq("id", 1).returning();
await db.delete("books").lt("year", 1900);
// Full-row upsert by PK (PUT /table).
await db.put("settings", { key: "theme", value: "dark" }).eq("key", "theme");Write tuning (chainable on any mutation): returning() minimal() dryRun()
maxAffected(n) minAffected(n) timezone(tz) buffered(), plus columns([...])
and missing("null"|"default") on inserts.
Media
await db.insert("merch", { slug: "tee" }).attach("image", file).returning(); // 1:1
await db.insert("merch", { slug: "tee" }).attachMany("images", files).returning(); // 1:NRPC
const r = await db.rpc("update_mastery", { p_id: 1, p_correct: true }); // POST
const s = await db.rpc("search", { q: "cat" }).get(); // cacheable GET
const t = await db.rpc("search", { q: "cat" }).viaQuery(); // QUERY verbRaw SQL (read-only)
POST /_sql — an arbitrary SELECT under the caller's role in a BEGIN READ ONLY
transaction (RLS + grants enforced, writes rejected). For joins/CTEs/windows the
structured grammar can't express. Capped at 1000 rows.
const { rows, truncated } = await db.sql("select count(*) n from books");
const { plan } = await db.sql("select * from big_view").explain(); // dry-runBatch
Bundle several ops into one round-trip (POST /_batch). Each runs through the
normal handler pipeline (same auth/RLS/Prefer) and commits independently.
const results = await db.batch()
.add("book", db.from("books").select("*").eq("id", 1))
.add("newTag", db.insert("tags", { name: "sci-fi" }))
.op("health", "GET", "/health") // raw escape hatch
.run();
// → [{ id, status, body }, ...]Bulk COPY
await db.copy("books", "isbn,title\n123,x\n", { columns: ["isbn", "title"] });Realtime
Postgres LISTEN/NOTIFY-backed change feed.
// One-shot catch-up (RLS-gated), page forward with the returned cursor:
const { changes, cursor } = await db.realtime.changes({ table: "books", since });
// Live streams — hand the URL to the platform primitive:
const es = new EventSource(db.realtime.sseUrl({ table: "books", accessToken: jwt }));
const ws = new WebSocket(db.realtime.wsUrl({ table: "books" }));Live shapes + channels (connect())
connect() opens one multiplexed WebSocket and speaks the protocol for you — no
manual framing. A shape is a filtered table subscription that MATERIALIZES
rows (insert/move-in adds, update mutates, delete/move-out removes) with an
initial snapshot; a channel is an ephemeral broadcast/presence topic with no
database round-trip (ideal for a driver's live GPS). Shapes reconnect and
re-snapshot automatically.
const rt = db.realtime.connect(); // uses global WebSocket, or pass { WebSocket }
// Customer watches ONE delivery: snapshot of current state, then live —
// bind the UI straight to the materialized rows.
const order = rt.shape(
{ table: "deliveries", where: "id.eq.42", snapshot: true, key: "id" },
{
onState: (rows) => renderOrder(rows[0]),
onUpToDate: ({ truncated }) => { if (truncated) pageRemainderViaRest(); },
onLagged: () => resyncFromScratch(),
},
);
// Driver's live position: ephemeral broadcast on a topic (no DB writes).
const chan = rt.channel("order:42", {
onBroadcast: (_event, pos) => moveMarker(pos), // customer receives
});
chan.broadcast("loc", { lat, lng }); // driver publishes ~1/s
// cleanup
rt.unsubscribe(order);
rt.close();Notes: hard deletes need deletes: true on the shape (the table must be
REPLICA IDENTITY FULL); channels need the server built with the
realtime-presence feature.
Advisory locks
const lock = await db.advisory.acquire(42, { wait: true });
if (lock.held) {
try { /* critical section */ } finally { await db.advisory.release(lock); }
}API keys
Per-caller key lifecycle. The raw secret is returned exactly once.
const { key } = await db.keys.create({ name: "ci" }); // stash `key` now
const { keys } = await db.keys.list();
await db.keys.rotate(id); // old dies, new raw returned once
await db.keys.revoke(id);Persisted queries
Register a request shape once, replay it by id (and lock the server down to only allow-listed queries if you want).
const { id } = await db.persisted.register(db.from("books").select("*").eq("author", "x"));
const rows = await db.persisted.run(id, { params: { limit: 10 } });Server introspection
await db.quota(); // GET /_quota — per-subject metering status
await db.openapi(); // GET /openapi.json
await db.codegen("ts"); // GET /_codegen/ts — typed client source ("ts"|"go"|"rs"|"py")Auth & headers
getToken is called before every request; return null when unauthenticated.
Pass static headers (e.g. Accept-Profile for a non-public schema) via
headers on createClient. Errors throw CloudRestError with .status and the
parsed PostgREST .body.
