JavaScript SDK
One typed client for the whole project — database, auth,
storage, and realtime — for browsers, Node, and edge runtimes. Published on
npm as @kethosbase/client.
Install
npm install @kethosbase/client
The package is dependency-free and ships ES modules with TypeScript types. It also loads from a module CDN or an import map without a build step:
import { createClient } from "@kethosbase/client";
Create a client
Pass the project's API URL and a key. Use the publishable key
(kbp_…) in anything that ships to a browser or device — it is
anon, and Row-Level Security decides what it can see. Keep the
secret key (kbs_…) on your own backend only.
import { createClient } from "@kethosbase/client";
const kb = createClient(
"https://<project-ref>.kethosbase.com",
"kbp_<publishable-key>"
);
The client is safe to create once and share. When a user signs in, the client stores the session and sends the user's access token on every request from then on, so your RLS policies apply automatically.
Query builder
Reach a table with from() and chain filters, ordering, and
range. Every builder is a thenable — await it, or call
.then() — and resolves to { data, error }. See the
REST data API for the wire behaviour behind each call.
const { data, error } = await kb
.from("todos")
.select("id, task, done")
.eq("done", false)
.order("id", { ascending: false })
.limit(20);
Filters
Each filter maps to one operator on the request. Chained filters combine with AND:
| Method | Matches |
|---|---|
.eq(col, v) / .neq(col, v) | equal / not equal |
.gt .gte .lt .lte | comparisons |
.like(col, p) / .ilike(col, p) | pattern (case-sensitive / -insensitive), * is the wildcard |
.is(col, null|true|false) | IS null / boolean |
.in(col, [a, b]) | any of a list |
.match(col, re) / .imatch(col, re) | POSIX regex (case-sensitive / -insensitive) |
.fts(col, q) / .plfts / .phfts / .wfts | full-text search variants |
.not(col, op, v) | negate any operator |
.or("a.eq.1,b.eq.2") | logical OR of a condition list (nestable) |
contains /
containedBy / overlaps) and filtering on an
embedded relation are not yet available — see the deferred list on the
REST page.Ordering, range, and count
const { data, count } = await kb
.from("todos")
.select("*", { count: "exact" }) // total matching rows, ignoring range
.order("created_at", { ascending: false, nullsFirst: false })
.range(0, 19); // rows 0–19 inclusive
{ count: "exact" } asks for the total; it comes back on the
result as count. .limit(n) is the alternative to
.range().
Single-row reads
const { data } = await kb.from("todos").select("*").eq("id", 1).single();
// .single() → the row, or an error if not exactly one row
// .maybeSingle() → the row, or null when there is no match
Embedding related tables
Pull rows from a related table in one call by naming it inside
select. The relationship is resolved through a foreign key: a
to-one FK embeds an object, a to-many FK embeds an array.
const { data } = await kb
.from("posts")
.select("id, title, author(*), comments(id, body)");
// → [{ id, title, author: { … }, comments: [ … ] }]
Use author!inner(*) to drop parent rows that have no match.
Embeds run under the same RLS as a direct read — they add reach, never
privilege. Full details and the current limits are on the
REST page.
Insert, update, upsert, delete
// insert one or many; add .select() to get the rows back
await kb.from("todos").insert({ task: "ship it" });
await kb.from("todos").insert([{ task: "one" }, { task: "two" }]).select();
// update the matching rows (a filter is required)
await kb.from("todos").update({ done: true }).eq("id", 1);
// upsert: insert, or merge on a conflict target (defaults to the primary key)
await kb.from("todos").upsert({ id: 1, task: "revised" }, { onConflict: "id" });
// delete the matching rows (a filter is required)
await kb.from("todos").delete().eq("done", true);
update and delete refuse to run
without a filter — there is no accidental full-table write.Calling functions and choosing a schema
// invoke a PostgreSQL function (POST /rest/v1/rpc/{fn})
const { data } = await kb.rpc("search_todos", { q: "ship" });
// target a non-public schema
const { data: rows } = await kb.schema("analytics").from("events").select("*");
Auth
Everything under kb.auth. A successful sign-in stores the
session on the client and starts the token auto-refresh; see the
Authentication page for token lifetimes and the wire
format.
// e-mail + password
await kb.auth.signUp({ email, password });
await kb.auth.signInWithPassword({ email, password });
// passwordless: e-mail code or magic link
await kb.auth.signInWithOtp({ email });
await kb.auth.verifyOtp({ email, token: "123456", type: "email" });
// guest session (no e-mail/password); convert later with updateUser
await kb.auth.signInAnonymously();
// social sign-in — redirects the browser to the provider
await kb.auth.signInWithOAuth({ provider: "google" });
Read and manage the current session:
const { data: { session } } = await kb.auth.getSession();
const { data: { user } } = await kb.auth.getUser();
// change the signed-in user's own e-mail, password, or profile metadata
await kb.auth.updateUser({ data: { display_name: "Alice" } });
// sign out this session, or every session on all devices
await kb.auth.signOut();
await kb.auth.signOut({ scope: "global" });
React to sign-in, sign-out, and token refresh:
const { data: { subscription } } = kb.auth.onAuthStateChange((event, session) => {
// event: "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED"
});
// later: subscription.unsubscribe();
The client refreshes the access token in the background before it expires, and retries a request once after a refresh if a token had just lapsed — so a long-lived session keeps working without app code.
Storage
Buckets and objects under kb.storage. Access follows the
Row-Level Security you write on storage.objects — see the
Storage page.
const bucket = kb.storage.from("avatars");
await bucket.upload("users/alice.png", file, { contentType: "image/png" });
const { data } = await bucket.download("users/alice.png");
await bucket.list("users/");
await bucket.remove(["users/alice.png"]);
// a time-limited link to one private object
const { data: signed } = await bucket.createSignedUrl("users/alice.png", 60);
// a public bucket needs no key — build the URL directly
const { data: { publicUrl } } = bucket.getPublicUrl("users/alice.png");
Realtime
Subscribe to a channel with kb.channel(), register handlers,
then .subscribe(). Broadcast reaches every subscriber; a
db:<table> channel streams row changes. The protocol is
documented on the Realtime page.
// broadcast: any subscriber can publish and receive
const room = kb
.channel("room:42")
.on("broadcast", { event: "chat" }, (msg) => console.log(msg.payload))
.subscribe();
room.send({ type: "broadcast", event: "chat", payload: { text: "hi" } });
// change feed: streams inserts/updates/deletes (needs the secret key)
kb.channel("db:todos")
.on("change", {}, ({ payload }) => console.log(payload.action, payload.record))
.subscribe();
// stop listening
await kb.removeChannel(room);
db: channel requires the secret key
(service_role) — use them from a backend that fans out to its
own users, never from a browser.The client reconnects automatically if the socket drops and re-subscribes its channels; a change feed is live-only, so changes made while disconnected are not replayed.
Errors
Calls resolve to { data, error } rather than throwing, so
check error before using data:
const { data, error } = await kb.from("todos").select("*");
if (error) {
console.error(error.message, error.code);
} else {
render(data);
}
See Limits & errors for the status codes and stable error codes behind these responses.