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
| Dependencies | None |
| Runtimes | Browsers, Node 18+ (22 for the built-in WebSocket), Deno, Bun, React Native |
| Module format | ESM, with types |
| Licence | Apache-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.
| Field | Meaning |
|---|---|
data | The parsed body, or null when the call failed or returned nothing |
error | A BaselyraError with code, status, details and isNetworkError |
count | The total from content-range, only when you asked for it |
status / statusText | The HTTP status, or 0 for a network failure |
error.code | When |
|---|---|
network_error | The request never reached the server — DNS, CORS, offline |
aborted | An AbortSignal fired |
no_session | refreshSession() with nobody signed in |
too_many_rows | maybeSingle() and more than one row came back |
| anything else | The 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)
| Method | Does |
|---|---|
.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
| Export | What it is |
|---|---|
createClient, BaselyraClient | The client |
BaselyraError, Http | The error class and the fetch wrapper |
QueryBuilder, AuthClient, StorageClient, BucketApi | The 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 |
| Types | Session, User, BaselyraResponse, AuthResponse, ClientOptions, Database helpers, PostgresChangesPayload, and the rest |
Failure modes
| What you see | Why | Fix |
|---|---|---|
error.status === 0, code network_error | CORS, DNS, or offline | Check CORS_ORIGINS on the server; * is the default but a production instance should name your origins |
| A Node script never exits | The refresh timer and the WebSocket are alive | await bl.dispose() |
| Every message arrives twice in React | A channel outlived a remount | Create it inside the effect, remove it in the cleanup |
No global WebSocket | Node before 22, or a runtime without one | Pass one as { realtime: { WebSocket } }, or disable realtime |
No global fetch | Node before 18 | Pass one as { global: { fetch } } |
| A session vanishes on reload in a Safari private window | localStorage threw, so the client fell back to memory | Expected. Supply a storage adapter if you need something else. |
| The user is signed out at random | A rotated refresh token was not stored — usually a second client instance | Hold exactly one createClient instance |
rpc() results cannot be narrowed… | A filter was chained onto rpc() | Filter inside the SQL function |