Baselyra Docs

JavaScript client

The official client is five source files and no dependencies. Every call resolves to { data, error } — nothing throws — and one WebSocket carries every channel. This page is the whole surface.

Install

npm install @baselyra/client
DependenciesNone
RuntimesBrowsers, Node 18+ (22 for the built-in WebSocket), Deno, Bun, React Native
Module formatESM, with types
LicenceApache-2.0

createClient

import { createClient } from '@baselyra/client';

export const bl = createClient(
  import.meta.env.VITE_BASELYRA_URL,
  import.meta.env.VITE_BASELYRA_ANON_KEY,
);

Hold one instance for the lifetime of the app: it owns the session, the refresh timer and the single WebSocket every channel shares. It throws — the only thing here that does — when the URL or the key is missing, because that is a programming error rather than a runtime condition.

createClient(url, anonKey, {
  auth: {
    persistSession: true,             // default
    autoRefreshToken: true,           // default
    storageKey: 'baselyra.auth.session',
    storage: myStorageAdapter,        // getItem / setItem / removeItem, synchronous
  },
  global: {
    headers: { 'x-client-info': 'acme-web/1.4.0' },
    fetch: myFetch,                   // a Node agent, a test double, a retrying wrapper
  },
  realtime: {
    enabled: true,                    // false never opens the socket
    heartbeatIntervalMs: 25_000,
    maxReconnectDelayMs: 30_000,
    WebSocket: MyWebSocket,
  },
});

Nothing throws

const { data, error, count, status } = await bl.from('posts').select('*');
if (error) {
  if (error.isNetworkError) return showOffline();   // status === 0
  return showMessage(error.message);                // error.code, error.status, error.details
}
render(data);

An HTTP error is not a rejected promise, and neither is a dead network. That is deliberate: a rejecting data call forces try/catch around every line that touches the database, and the one you forget in a React event handler becomes an unhandled rejection instead of a message next to the form field.

FieldMeaning
dataThe parsed body, or null when the call failed or returned nothing
errorA BaselyraError with code, status, details and isNetworkError
countThe total from content-range, only when you asked for it
status / statusTextThe HTTP status, or 0 for a network failure
error.codeWhen
network_errorThe request never reached the server — DNS, CORS, offline
abortedAn AbortSignal fired
no_sessionrefreshSession() with nobody signed in
too_many_rowsmaybeSingle() and more than one row came back
anything elseThe server's own code, verbatim

Two things still throw, both programming errors: createClient without a URL or key, and a filter on an rpc() result the server cannot honour.

Queries

The builder is a thenable, so a chain runs when you await it — there is no terminal .execute() to remember, and awaiting the same chain twice sends one request.

const { data, error, count } = await bl
  .from('posts')
  .select('id,title,tags,author:users(id,name)', { count: 'exact' })
  .eq('published', true)
  .neq('kind', 'draft')
  .gt('score', 10).gte('score', 10).lt('score', 99).lte('score', 99)
  .like('title', '*sql*').ilike('title', '*SQL*')     // * is the wildcard
  .is('deleted_at', null)                             // the only correct null test
  .in('id', [1, 2, 3])
  .contains('tags', ['sql'])                          // @>
  .containedBy('tags', ['sql', 'db'])                 // <@
  .overlaps('tags', ['sql', 'db'])                    // &&
  .match({ status: 'open', kind: 'bug' })             // eq on every key
  .not('state', 'eq', 'archived')
  .or('status.eq.draft,and(views.gte.100,pinned.is.true)')
  .textSearch('search', 'postgres -mysql', { type: 'websearch', config: 'english' })
  .order('created_at', { ascending: false, nullsFirst: false })
  .range(0, 19);                                      // or .limit(20)
MethodDoes
.select(columns, { count, head })Choose columns. After a write it asks for the affected rows back. head: true sends HEAD, so a count crosses the wire without rows.
.insert(values, { count })One row or an array. Nothing comes back unless .select() is chained.
.upsert(values, { onConflict, ignoreDuplicates, count })Insert, or update the row holding the conflicting key
.update(values, { count })Needs a filter, or .unsafeMutation()
.delete({ count })Needs a filter, or .unsafeMutation()
.filter(column, op, value)Any operator the server knows, including ones without a named method
.single()Expect exactly one row; data is the row. 0 or 2+ is a 406.
.maybeSingle()Expect zero or one; data is the row or null
.csv()Render the rows as RFC 4180 CSV, client-side
.abortSignal(signal)Cancel; the result is an error with code aborted
.build()The request this chain would send, without sending it
await bl.from('posts').insert({ title: 'Hello' }).select().single();
await bl.from('posts').upsert(rows, { onConflict: 'slug' });
await bl.from('posts').update({ title: 'x' }).eq('id', 1);
await bl.from('posts').delete().eq('id', 1);
await bl.from('posts').delete().unsafeMutation();      // every row. Deliberate.
await bl.rpc('search_posts', { term: 'sql' });

Typed rows

import type { Database } from './database.types';

const bl = createClient<Database>(url, ANON_KEY);
const { data } = await bl.from('posts').select('*');   // data: Post[] | null

The type only needs the shape { public: { Tables: { posts: { Row: Post } } } }. Without it, rows are open records and everything still works. The VS Code extension writes that file from your live schema.

Auth

await bl.auth.signUp({ email, password, data: { name: 'Ada' } });
await bl.auth.signInWithPassword({ email, password });
await bl.auth.signInWithOtp({ email, type: 'magiclink' });      // or type: 'otp'
await bl.auth.verifyOtp({ email, token: '123456', type: 'otp' });
await bl.auth.resetPasswordForEmail(email);
await bl.auth.resend({ type: 'confirmation', email });
await bl.auth.updateUser({ password: 'a-new-one', data: { theme: 'dark' } });
await bl.auth.refreshSession();
await bl.auth.signOut({ scope: 'global' });

const { data: { session } } = await bl.auth.getSession();   // refreshes first if expired
const { data: { user } } = await bl.auth.getUser();         // asks the server
bl.auth.accessToken;                                        // the raw token, no round trip
bl.auth.setSession(session);                                // adopt one from elsewhere
bl.auth.stopAutoRefresh();                                  // let a Node process exit
const { data: { subscription } } = bl.auth.onAuthStateChange((event, session) => {
  setUser(session?.user ?? null);
});
// SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED | USER_UPDATED
return () => subscription.unsubscribe();

The access token is renewed a minute before it expires, and the renewed token is re-attached to the realtime socket automatically. A refresh that fails with a 4xx clears the session and emits SIGNED_OUT — the token is gone for good, rotated or revoked. Anything else is treated as the network and retried in ten seconds.

signInWithOtp is email-only. A phone code is two plain fetch calls; see Phone one-time codes.

Storage

const bucket = bl.storage.from('avatars');

await bucket.upload('me.png', file, { upsert: true, contentType: 'image/png' });
await bucket.update('me.png', file);                       // always overwrites
await bucket.list('teams/', { limit: 100, sortBy: { column: 'name', order: 'asc' } });
await bucket.move('me.png', 'archive/me.png');
await bucket.copy('me.png', 'archive/me.png');
await bucket.remove(['me.png', 'old.png']);

const { data: blob } = await bucket.download('me.png');    // data is a Blob
const { data: signed } = await bucket.createSignedUrl('me.png', 3600);
bucket.getPublicUrl('me.png');                             // no request; public buckets only

await bl.storage.listBuckets();
await bl.storage.getBucket('avatars');
await bl.storage.createBucket('invoices', { public: false });   // service key
await bl.storage.updateBucket('invoices', { file_size_limit: 10485760 });
await bl.storage.deleteBucket('invoices');                      // refused while not empty

Uploads accept a browser File/Blob, a Node Buffer, an ArrayBuffer, a string or FormData. The bytes are sent as the raw request body, so nothing is copied through a multipart encoder. remove() stops at the first failure and returns what was already deleted, so a partial result is visible rather than silent.

Realtime

const channel = bl.channel('room:' + roomId);

channel
  .on('postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'messages', filter: 'room_id=eq.' + roomId },
      ({ new: row }) => append(row))
  .on('broadcast', { event: 'typing' }, ({ payload }) => showTyping(payload))
  .on('presence', { event: 'sync' }, ({ state }) => setHere(Object.values(state)));

const status = await channel.subscribe();      // SUBSCRIBED | CHANNEL_ERROR | TIMED_OUT | CLOSED
await channel.track({ name: 'Ada' });
await channel.send({ type: 'broadcast', event: 'typing', payload: { name: 'Ada' } });
channel.presence();                            // everyone here, keyed by connection id

bl.getChannels();
await bl.removeChannel(channel);
await bl.removeAllChannels();

const off = bl.realtime.onError((message) => console.warn('realtime:', message));

send() and track() resolve to 'ok' or 'buffered' — the socket may be down, in which case up to 200 frames are queued and flushed on reconnect. A broadcast is not echoed to its sender, so update your own UI locally.

A complete chat component

import { useEffect, useState } from 'react';
import { bl } from './lib/baselyra';

type Message = { id: number; body: string; author: string };

export function Chat({ roomId, name }: { roomId: number; name: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [typing, setTyping] = useState<string[]>([]);
  const [here, setHere] = useState<string[]>([]);
  const [draft, setDraft] = useState('');
  const topic = 'room:' + roomId;

  useEffect(() => {
    // Created inside the effect: the cleanup drops it, so a remount binds a
    // fresh channel instead of stacking a second set of callbacks on the old one.
    const channel = bl.channel(topic);

    bl.from('messages').select('id,body,author')
      .eq('room_id', roomId).order('created_at').limit(50)
      .then(({ data }) => setMessages(data ?? []));

    channel
      .on<Message>('postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages', filter: 'room_id=eq.' + roomId },
        ({ new: row }) => setMessages((prev) => (row ? [...prev, row] : prev)))
      .on('broadcast', { event: 'typing' }, ({ payload }) => {
        const who = (payload as { name: string }).name;
        setTyping((prev) => [...new Set([...prev, who])]);
        setTimeout(() => setTyping((prev) => prev.filter((n) => n !== who)), 2000);
      })
      .on('presence', { event: 'sync' }, ({ state }) =>
        setHere(Object.values(state).map((m) => (m as { name: string }).name)));

    channel.subscribe().then((status) => {
      if (status === 'SUBSCRIBED') void channel.track({ name });
    });

    return () => { void bl.removeChannel(channel); };
  }, [topic, name]);

  async function send(event: React.FormEvent) {
    event.preventDefault();
    const body = draft.trim();
    if (!body) return;
    setDraft('');
    // The INSERT comes back over the channel above, so nothing is appended here.
    const { error } = await bl.from('messages').insert({ room_id: roomId, body, author: name });
    if (error) alert(error.message);
  }

  return (
    <form onSubmit={send}>
      <p>{here.length} here{typing.length > 0 && ' · ' + typing.join(', ') + ' typing…'}</p>
      <ul>{messages.map((m) => <li key={m.id}><b>{m.author}</b> {m.body}</li>)}</ul>
      <input value={draft} placeholder="Message" onChange={(e) => {
        setDraft(e.target.value);
        void bl.channel(topic).send({ type: 'broadcast', event: 'typing', payload: { name } });
      }} />
    </form>
  );
}

The table needs its trigger and its policies first — select baselyra.enable_realtime('public.messages'), and policy set 4.

AI

const { data, error } = await bl.ai.chat(
  [{ role: 'user', content: 'Summarise this order.' }],
  { system: 'You are a support assistant for Acme.', signal: controller.signal },
);
console.log(data?.content);

const { data: status } = await bl.ai.status();   // { enabled, model }

The relay needs a signed-in user token, not the anon key, and answers 503 when no key is configured on the server. Streaming is not in the client; use fetch against /ai/v1/chat with stream: true and read the SSE frames.

Node scripts and tests

import { createClient } from '@baselyra/client';

const bl = createClient(process.env.BASELYRA_URL, process.env.BASELYRA_SERVICE_KEY, {
  auth: { persistSession: false, autoRefreshToken: false },
  realtime: { enabled: false },
});

const { data } = await bl.from('profiles').select('id,email').eq('plan', 'free');

await bl.dispose();   // release the refresh timer and the socket, or the process hangs

Passing the service key as the key runs every request as service_role, which bypasses RLS entirely — right for a batch job, never for anything a browser downloads.

Exports

ExportWhat it is
createClient, BaselyraClientThe client
BaselyraError, HttpThe error class and the fetch wrapper
QueryBuilder, AuthClient, StorageClient, BucketApiThe pieces, for extending or testing
RealtimeClient, RealtimeChannel
backoffDelay(attempt, maxMs, random?)The reconnect delay, exported because a backoff nobody can test is a backoff nobody trusts
matchesFilter(filter, row)The client-side filter check — presentation, never authorisation
TypesSession, User, BaselyraResponse, AuthResponse, ClientOptions, Database helpers, PostgresChangesPayload, and the rest

Failure modes

What you seeWhyFix
error.status === 0, code network_errorCORS, DNS, or offlineCheck CORS_ORIGINS on the server; * is the default but a production instance should name your origins
A Node script never exitsThe refresh timer and the WebSocket are aliveawait bl.dispose()
Every message arrives twice in ReactA channel outlived a remountCreate it inside the effect, remove it in the cleanup
No global WebSocketNode before 22, or a runtime without onePass one as { realtime: { WebSocket } }, or disable realtime
No global fetchNode before 18Pass one as { global: { fetch } }
A session vanishes on reload in a Safari private windowlocalStorage threw, so the client fell back to memoryExpected. Supply a storage adapter if you need something else.
The user is signed out at randomA rotated refresh token was not stored — usually a second client instanceHold exactly one createClient instance
rpc() results cannot be narrowed…A filter was chained onto rpc()Filter inside the SQL function

Edit this page Report a problem

Esc
navigate open Esc close