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:

MethodMatches
.eq(col, v) / .neq(col, v)equal / not equal
.gt .gte .lt .ltecomparisons
.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 / .wftsfull-text search variants
.not(col, op, v)negate any operator
.or("a.eq.1,b.eq.2")logical OR of a condition list (nestable)
Containment filters (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);
Change feeds bypass Row-Level Security, so subscribing to a 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.