SDKs & integrations
JavaScript SDK
Use @orbitneststudio/js in the browser for auth, database queries, storage, realtime, edge functions and analytics with your project's anon key.
@orbitneststudio/js is the browser SDK for OrbitNest, for React, Vue, Svelte and plain JavaScript apps. It has the same API as the Node.js SDK, adds session persistence, and leaves out the filesystem migration runner. This page describes version 0.7.0. Version 0.7.0 isn't on npm yet, so npm install still gets 0.6.0. Behavior that is new in 0.7.0 is marked "from version 0.7.0".
Install
npm install @orbitneststudio/jsThe package ships ESM, CommonJS and a minified IIFE bundle for <script> tags. The IIFE build exposes a global named OrbitNest:
<script src="https://cdn.jsdelivr.net/npm/@orbitneststudio/js@0.6.0"></script>
<script>
const orbit = OrbitNest.createClient({ apiKey: 'YOUR_ANON_KEY' });
</script>It targets ES2020 and needs fetch, WebSocket, FormData and Blob. That covers every modern browser, and Node.js 18+ if you share code between the browser and a server.
Keys and environments
Copy your keys from your project's API Keys page in Studio. See API keys & JWT for how they work.
| Where the code runs | Key to use |
|---|---|
| Browser, mobile web, anything a user can inspect | Anon key |
| Your own server, a build step, CI | Service-role key, with the Node.js SDK |
Never ship a service-role key to the browser
The service-role key bypasses row-level security and can run DDL. Anyone who opens your bundle can read it. Browser code gets the anon key only.
What the anon key can do
Every database, storage and function request sends the key you pass to createClient in the apikey header. From version 0.7.0, while a user is signed in, the Authorization header carries that user's access token (Bearer <user token>). With nobody signed in it carries the key. The API then applies row-level security as that user, and edge functions receive them as ctx.user. In 0.6.0 the user token was never sent on these requests.
| Area | Anon key, nobody signed in | Anon key, user signed in (from 0.7.0) | Needs the service-role key |
|---|---|---|---|
Raw SQL and the query builder (db.query(), db.from()) | One SELECT or INSERT statement, as anon | Same, as the user, with RLS | UPDATE, DELETE, DDL |
| Row helpers | insert(), getTableData() | Also update(), delete(), bulkUpdate() and bulkDelete(). All of these run as the user, with RLS. | bulkInsert() |
| RLS helpers | — | — | listPolicies(), enableRls(), disableRls(), createPolicy(), deletePolicy() |
| Storage | list() and download() in public buckets, getPublicUrl() | Also upload() and remove(). In private buckets, only the user's own files. | createBucket(), and every file in every bucket |
| Edge functions | invoke() | invoke(), with ctx.user set | — |
| Realtime | All channel features | Events are re-checked against RLS as the user | — |
Raw SQL and the query builder go through the SQL endpoint, which doesn't take the user's identity. When a read or write must respect per-user policies, use the row helpers, or run it from an edge function or your own server.
Create a client
import { createClient, localStorageAdapter } from '@orbitneststudio/js';
export const orbit = createClient({
apiKey: import.meta.env.VITE_ORBITNEST_ANON_KEY,
storage: localStorageAdapter, // keep users signed in across page reloads
});apiKey is the only required option. The SDK decodes the project slug from the key's project_slug claim.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Your project's anon key (a JWT). |
projectSlug | string | Decoded from apiKey | Override the project slug. |
baseUrl | string | https://api.orbitnest.io | API origin. A trailing slash is removed. |
timeout | number | 30000 | Per-request timeout in milliseconds. Storage uploads and downloads use a fixed 30-second timeout. |
storage | StorageAdapter | In-memory | Where the auth session is persisted. See Session persistence. |
analyticsToken | string | none | Ingest-only analytics token, sent as x-analytics-token on analytics requests. |
realtimeOptions | { WebSocket?, userJwt?, accessToken? } | globalThis.WebSocket | A custom WebSocket constructor. From version 0.7.0 the socket follows the signed-in user automatically. A fixed userJwt overrides that. See Behavior and limits. |
createClient throws when apiKey is missing, is not a three-part JWT, or has no project_slug claim.
The client exposes these namespaces:
| Property | What it does |
|---|---|
db | Query builder, raw SQL, row helpers and RLS helpers |
auth | Email and password, email OTP sign-up, SMS OTP, TOTP MFA, passkeys |
storage | storage.from(bucket) for upload, download, list, remove and public URLs |
channel(name) / realtime | Postgres changes, broadcast and presence over one WebSocket |
functions | Invoke edge functions |
analytics | Batched event tracking |
jobs, env, logs | Deprecated. They don't work with project keys. See Admin namespaces. |
Session persistence
By default the session lives only in the client instance and is lost on reload. Pass a storage adapter to persist it:
import {
createClient,
localStorageAdapter,
createMemoryAdapter,
type StorageAdapter,
} from '@orbitneststudio/js';
createClient({ apiKey, storage: localStorageAdapter }); // survives reloads
createClient({ apiKey }); // this client only (the default)
// Any object with getItem / setItem / removeItem works:
const sessionStorageAdapter: StorageAdapter = {
getItem: (k) => sessionStorage.getItem(k),
setItem: (k, v) => sessionStorage.setItem(k, v),
removeItem: (k) => sessionStorage.removeItem(k),
};The session is stored under orbitnest.session.<project-slug>, so clients for different projects on the same origin don't overwrite each other. The package also exports memoryAdapter, createMemoryAdapter() and createLocalStorageAdapter().
From version 0.7.0, sessions are safe to use in server-side rendering:
- The default adapter,
memoryAdapter, stores nothing. Each client keeps its own session in the instance, so one request's user never leaks into another client in the same server process. localStorageAdapterlooks uplocalStorageon every call. Where it doesn't exist (on a server) or throws (Safari private mode, quota errors), it persists nothing, so it's always safe to pass.createMemoryAdapter()returns one in-memory store. Pass the same instance to several clients only when you want them to share a session.
In 0.6.0, memoryAdapter and the server fallback of localStorageAdapter were one module-level store shared by every client in the process. With 0.6.0 on a server, pass storage: createMemoryAdapter() to each per-request client.
Database
Query builder
orbit.db.from(table) returns a chainable builder. It compiles to one parameterized SELECT and runs it through the SQL endpoint. Values are always bound ($1, $2, …) and never interpolated.
const { data, error } = await orbit.db
.from('posts')
.eq('status', 'published')
.gt('views', 100)
.order('created_at', { ascending: false })
.limit(10)
.select('id, title, views');
if (error) throw new Error(error.message);
console.log(data.rows); // data: { rows, total }Filters. Filters combine with AND.
| Method | SQL |
|---|---|
eq(column, value) | column = $n |
neq(column, value) | column != $n |
gt(column, value) / gte(column, value) | > / >= |
lt(column, value) / lte(column, value) | < / <= |
like(column, pattern) / ilike(column, pattern) | LIKE / ILIKE |
in(column, values: unknown[]) | column IN (...). An empty array matches nothing. |
is(column, value: null | boolean) | IS NULL for null, otherwise = value |
textSearch(column, query, opts?) | Postgres full-text search. See Full-text search. |
nearest(column, vector, opts?) | pgvector similarity ordering. See Vector search. |
Ordering and pagination.
| Method | Behavior |
|---|---|
order(column, { ascending?: boolean }) | Ascending by default. Call it more than once to sort by several columns. |
limit(n) | LIMIT n |
range(from, to) | Inclusive row range: range(0, 9) returns the first 10 rows. |
page(n, size = 20) | 1-based page of size rows |
orderBy(column, 'ASC' | 'DESC') | Deprecated alias for order() |
Running the query. These methods return a promise:
| Method | Returns |
|---|---|
select<T>(columns = '*') | { rows: T[], total }. total is the number of rows returned, not the table count. |
single<T>(columns = '*') | The first row. Returns an error with code NOT_FOUND when no row matches. |
maybeSingle<T>(columns = '*') | The first row, or null with no error |
columns accepts * or a comma-separated list of plain column names. Aliases, casts and joins aren't supported. Table and column names must match ^[a-zA-Z_][a-zA-Z0-9_]*$. Any other name makes the builder throw OrbitNest: unsafe SQL identifier.
Writes. The builder's write methods ignore any filters on the chain. update and delete target a single row by its id column:
const { data: post } = await orbit.db.from('posts').insert({ title: 'Hello' }); // the inserted row
await orbit.db.from('posts').update(post.id, { title: 'Edited' }); // signed-in user (from 0.7.0) or service-role key
await orbit.db.from('posts').delete(post.id); // signed-in user (from 0.7.0) or service-role keyupdate returns the updated row.
The builder has no upsert, or filter or rpc. To call a Postgres function, use db.query('SELECT * FROM my_function($1)', [arg]).
Full-text search
await orbit.db
.from('articles')
.textSearch('body', 'postgres -mysql', { type: 'websearch', config: 'english' })
.select('id, title');This compiles to to_tsvector(config, column::text) @@ <fn>(config, $n), where <fn> is websearch_to_tsquery (the default type), plainto_tsquery (plain) or phraseto_tsquery (phrase). config defaults to english and must be a plain identifier.
Vector search
nearest(column, vector, { metric?, limit? }) orders rows by pgvector distance from vector, closest first. You can combine it with filters:
await orbit.db
.from('documents')
.eq('workspace_id', workspaceId)
.nearest('embedding', queryEmbedding, { metric: 'cosine', limit: 5 })
.select('id, title');metric | Operator |
|---|---|
l2 (default) | <-> |
cosine | <=> |
ip | <#> |
The vector extension must be enabled on the project, and the column must be of type vector. See Database. nearest() throws if vector contains anything other than finite numbers.
Raw SQL
const { data, error } = await orbit.db.query(
'SELECT id, title FROM posts WHERE author_id = $1 ORDER BY created_at DESC',
[authorId],
);
// data: { rows, rowCount, fields: [{ name, dataType }] }Always pass user input through params. With the anon key, the statement must start with SELECT, SHOW, DESCRIBE or INSERT, a call can hold only one statement, and tables whose names start with auth_ are off-limits.
Table and RLS helpers
| Method | Key | Notes |
|---|---|---|
listTables() | anon or service | string[] of table names |
getTableMetadata(table) | anon or service | From version 0.7.0, returns TableMetadata[] (one entry per matching table, empty when the table doesn't exist). Each entry has table_name, rls_enabled and columns with column_name, data_type, is_nullable and column_default. |
getTableData(table, { page?, limit?, sortBy?, sortOrder? }) | anon, user or service | From version 0.7.0, returns { rows, total }. With a signed-in user, rows are read under that user's RLS policies. In 0.6.0, data was the raw API payload (data.data and data.total_rows). |
insert(table, row) | anon, user or service | Returns the inserted row |
update(table, id, data) / delete(table, id) | user or service | Matches on the id column. update returns the updated row. |
bulkInsert(table, rows) | service | Returns the inserted rows |
bulkUpdate(table, [{ where, data }]) / bulkDelete(table, [where]) | user or service | where is an object of column/value equality pairs. From version 0.7.0, they return { updated } and { deleted }, the number of rows affected. |
enableRls(table) / disableRls(table) | service | |
listPolicies(table) | service | From version 0.7.0, returns RlsPolicy[] with name, command, definition, check and roles. |
createPolicy(table, policy) / deletePolicy(table, name) | service | policy is an RlsPolicy: name, command, definition (the USING expression), and optional check and roles. |
"User" means the anon key plus a signed-in user, which needs version 0.7.0. RLS applies to those calls.
Generate types
generateTypes(db, opts?) reads your live schema and returns TypeScript source: one interface per table, plus a Database map.
import { generateTypes } from '@orbitneststudio/js';
const source = await generateTypes(orbit.db, { tables: ['posts', 'profiles'] });By default it skips tables matching /^(auth_|_)/. Pass tables to choose tables, or exclude (a RegExp) to change the skip rule. In most projects, run orbitnest gen types from the CLI in a build step instead.
Authentication
orbit.auth covers end-user sign-up and sign-in for your project. See Authentication for project-side settings such as SMTP, Twilio and token lifetimes. The SDK has no OAuth (social) sign-in methods.
Email and password
Sign-up takes two steps. signUp emails a code (6 digits by default), and verifySignUp creates the account and starts a session:
await orbit.auth.signUp({ email, password, metadata: { full_name: 'Ada' } });
const { data: session, error } = await orbit.auth.verifySignUp({ email, code: '1234' });Sign in:
import { isMfaChallenge } from '@orbitneststudio/js';
const { data, error } = await orbit.auth.signIn({ email, password });
if (error) throw new Error(error.message);
if (isMfaChallenge(data)) {
// The account has MFA. No session yet.
await orbit.auth.verifyMfa(data.challenge_token, codeFromUser);
}
const user = orbit.auth.getUser();Method reference
| Method | Description |
|---|---|
signUp({ email, password, metadata? }) | Sends a sign-up code. Returns { success, message, email, expires_in }. |
verifySignUp({ email, code }) | Creates the user and stores the session |
signIn({ email, password }) | Returns an AuthSession, or an MfaChallenge when MFA is enrolled |
verifyMfa(challengeToken, code) | Completes an MFA sign-in with a TOTP code or a recovery code |
signInWithSms(phone) | Sends a 6-digit code to an E.164 number such as +15555550123 |
verifySmsOtp(phone, code) | Signs in, creating the user on first sign-in, and stores the session |
resetPasswordForEmail({ email }) | Emails a password-reset code |
updatePassword({ email, token, password }) | Sets a new password using the emailed code (token) |
signOut() | Signs out on the server and clears the stored session |
refreshSession() | Exchanges the stored refresh token for a new session |
getAccessToken() | From version 0.7.0. Returns the current access token, refreshing it first when it expires within 30 seconds. Returns null when signed out or when the refresh fails. |
onAuthStateChange(cb) | From version 0.7.0. Calls cb(session) on sign-in, token refresh and sign-out (null). Returns an unsubscribe function. |
getSession() / getUser() | Synchronous reads of the stored session and user |
setSession(session) | Stores a session you obtained elsewhere |
getProfile() | Fetches the current user from the API |
updateUser({ email?, password?, current_password?, metadata? }) | Updates the signed-in user and returns the updated user |
verifyEmailChange(code) | Confirms a pending email change with the code sent to the new address |
deleteUser() | Deletes the signed-in user's account and clears the session |
From version 0.7.0:
- Changing
emailorpasswordwithupdateUserneedscurrent_password. Accounts without a password (passkey, SMS) need a session signed in within the last 10 minutes instead. Otherwise the API returns401with codeREAUTH_REQUIRED. An email that another user already has returns409. A new email is not applied yet: the returned user hasnew_email, and a code is sent to that address forverifyEmailChange. See Change the email address. metadatais merged into the user'suser_metadata.deleteUser()works. The account is removed along with its sessions, MFA factors and passkeys.
SMS sign-in only works once the project has Twilio configured. See Authentication. Each phone number can request one code per minute and five per hour. Past that, signInWithSms returns 429 with code SMS_RATE_LIMITED. After otp_config.max_attempts wrong codes (3 by default), verifySmsOtp returns 429 with code OTP_RATE_LIMITED.
Sessions
verifySignUp, signIn, verifyMfa, verifySmsOtp, signUpWithPasskeyVerify and signInWithPasskeyVerify store the returned session { access_token, refresh_token, expires_in, token_type, user } in the configured storage adapter.
- Access tokens last 15 minutes and refresh tokens 7 days unless the project's JWT settings override those defaults.
- From version 0.7.0, the SDK records when the access token expires (
expires_at) and refreshes it 30 seconds before then, just before the next request that needs it. Concurrent requests share one refresh. Only a401from the refresh endpoint clears the session. Other errors, such as402,403or429, leave it in place. In 0.6.0 nothing refreshed automatically, so you had to callrefreshSession()yourself. - From version 0.7.0, the user's access token is sent on database, storage and function calls as well as on the user endpoints. See What the anon key can do.
- From version 0.7.0,
onAuthStateChangereports session changes. In 0.6.0, readgetSession()orgetUser()after each auth call.
MFA (TOTP)
Enrollment needs a signed-in user:
const { data: enroll } = await orbit.auth.enrollMfaTotp('My phone');
// enroll: { factor_id, secret, otpauth_url, qr_code }. Show qr_code (a data URL) or secret.
const { data: confirmed } = await orbit.auth.verifyMfaEnrollment(enroll.factor_id, code);
// confirmed.recovery_codes is shown once. Ask the user to store the codes safely.| Method | Description |
|---|---|
enrollMfaTotp(friendlyName?, code?) | Starts TOTP enrollment |
verifyMfaEnrollment(factorId, code) | Confirms enrollment and returns { success, recovery_codes }. recovery_codes is empty when the user still has unused codes. |
listMfaFactors() | MfaFactor[] |
unenrollMfa(factorId, code?) | Removes a factor |
regenerateMfaRecoveryCodes(code?) | Returns a new set of codes as { codes } |
Once the user has a verified factor, enrollMfaTotp, unenrollMfa and regenerateMfaRecoveryCodes need code: a current TOTP code or a recovery code. The code parameter is new in version 0.7.0. Without it the API returns 401 with code MFA_REAUTH_REQUIRED, and too many wrong codes return 429.
Passkeys
The SDK fetches WebAuthn options and posts the results back, and your app runs the browser ceremony. With @simplewebauthn/browser:
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
// Sign up with a passkey (no password)
const { data: reg } = await orbit.auth.signUpWithPasskeyOptions({
email,
user_metadata: { full_name: 'Ada' }, // shown in the native "save passkey" sheet
});
const attestation = await startRegistration({ optionsJSON: reg.publicKey });
await orbit.auth.signUpWithPasskeyVerify({ challenge_id: reg.challenge_id, attestation });
// Sign in with a passkey
const { data: login } = await orbit.auth.signInWithPasskeyOptions({ identifier: email });
const assertion = await startAuthentication({ optionsJSON: login.publicKey });
await orbit.auth.signInWithPasskeyVerify({ challenge_id: login.challenge_id, assertion });| Method | Session required | Description |
|---|---|---|
signUpWithPasskeyOptions({ email, user_metadata?, device_name? }) | No | Returns 409 if an account with that email already exists. Sign the user in, then add a passkey with registerPasskeyOptions. |
signUpWithPasskeyVerify({ challenge_id, attestation, device_name? }) | No | Creates the user and stores the session |
signInWithPasskeyOptions({ identifier? }) | No | |
signInWithPasskeyVerify({ challenge_id, assertion }) | No | Stores the session |
registerPasskeyOptions({ device_name? }) | Yes | Adds a passkey to the current user |
registerPasskeyVerify({ challenge_id, attestation, device_name? }) | Yes | Returns the PasskeyDevice |
listPasskeys() / renamePasskey(id, name) / revokePasskey(id) | Yes | Manage the user's passkeys |
Storage
const avatars = orbit.storage.from('avatars');
const { data: blob, error } = await avatars.download('users/42.png');
const { data } = avatars.getPublicUrl('users/42.png', {
transform: { width: 128, height: 128, format: 'webp', quality: 80, fit: 'cover' },
});
img.src = data.publicUrl;| Method | Returns | Notes |
|---|---|---|
upload(path, file, { upsert?, contentType? }) | StorageFile | file is a Blob, File, ArrayBuffer or Uint8Array. Maximum 50 MB. Needs a signed-in user or the service-role key. The anon key alone gets 403. |
download(path) | Blob | |
list(prefix?, { limit? }) | File list | The server defaults limit to 100 |
remove(paths) | { deleted, errors } | Needs a signed-in user (own files only) or the service-role key. The anon key alone gets 403. |
getPublicUrl(path, { transform? }) | { data: { publicUrl } } | Synchronous. Builds /api/public/<slug>/storage/<bucket>/<path>, which works only for public buckets. |
createBucket({ public? }) | { bucket, created } | Service-role key only. A new bucket is private unless you pass public: true. Leave public out to keep an existing bucket's visibility. |
From version 0.7.0:
contentTypesets the stored MIME type. Without it, the SDK uses theBlob's own type, then guesses from the file extension for common types, then falls back toapplication/octet-stream. The oldcacheControloption is deprecated and ignored.createBucketaccepts{ public }.list(),remove()andcreateBucket()return the payload directly. In 0.6.0 they returned the API's{ data, error }envelope asdata, so the payload sat atdata.data.- Upload and download errors carry the server's message and
code.
transform accepts width and height (capped at 4000), format (webp, jpeg, png, avif), quality, and fit (cover, contain, fill, inside, outside). Paths containing .. or a null byte return a VALIDATION_ERROR. getPublicUrl throws for them instead. See Storage for buckets and transforms.
Realtime
const channel = orbit
.channel('orders')
.on('postgres_changes',
{ event: 'INSERT', table: 'orders', filter: { status: { eq: 'paid' } } },
(e) => console.log(e.op, e.new))
.on('broadcast', { event: 'cursor' }, (msg) => moveCursor(msg.payload))
.subscribe((status, err) => console.log(status, err));
await channel.send('cursor', { x: 10, y: 20 });
await channel.unsubscribe();
orbit.realtime.disconnect(); // close the socket and drop all channels| Method | Description |
|---|---|
orbit.channel(name) | Gets or creates a channel |
.on('postgres_changes', { event, table, schema?, filter? }, cb) | event is 'INSERT', 'UPDATE', 'DELETE', '*', or an array of them. schema defaults to public. |
.on('broadcast', { event }, cb) | event: '*' matches every event |
.subscribe(cb?) | Connects and subscribes. cb(status, err?) receives connecting, connected, disconnected or error. |
.send(event, payload) | Broadcasts to the channel. Throws Not connected if the socket can't open. |
.unsubscribe() | Removes the channel. The socket closes when no channels remain. |
From version 0.7.0, unsubscribe() removes the channel's server subscriptions (table:<schema>.<table> and broadcast:<name>). If another channel still listens to the same table, that subscription stays. In 0.6.0 the server subscriptions stayed active for as long as the socket was open.
The server keeps one subscription per table on each socket. When several handlers watch the same table, the SDK subscribes to the union of their events and then checks each handler's own event and filter in the client.
A change payload has the shape { op, schema, table, new, old, commit_ts }. filter maps column names to conditions (eq, neq, in, gt, lt, like), and every condition must match. Conditions are checked against the new row for inserts and updates, and the old row for deletes. Changes are only emitted for tables with realtime enabled. See Realtime.
Presence
const room = orbit
.channel('room-1')
.on('broadcast', { event: '*' }, () => {}); // subscribes the socket to the channel
room.presence
.onSync((state) => render(state))
.onJoin((key, presences) => console.log('joined', key))
.onLeave((key, presences) => console.log('left', key));
room.subscribe();
await room.presence.track({ name: 'Ada' });
// later: await room.presence.untrack();getState() returns the last roster. Presence travels on the channel's broadcast stream, so register a broadcast handler, as shown above, to receive other users' presence updates.
From version 0.7.0, untrack() sends a presence.untrack message. It removes only your presence and keeps the channel's broadcast subscription. Servers older than this message ignore it, so your presence then stays until you unsubscribe or disconnect.
Behavior and limits
- Authentication. The API key is sent in the WebSocket subprotocol, never in the URL.
- Row-level security. A connection made with only the anon key receives every change on enabled tables. With a user JWT on the socket, each event is re-checked against RLS as that user. From version 0.7.0, the client resolves the signed-in user's access token (refreshed if needed) before each connect, and re-opens the socket when the session changes. A fixed
realtimeOptions.userJwtturns this off and always wins. To change that fixed token later, callorbit.realtime.setAuth(token). In 0.6.0, passrealtimeOptions: { userJwt: session.access_token }yourself. - Broadcast rules. You must subscribe to a broadcast channel (register an
on('broadcast', …)handler) before yousend(). The sender also receives its own messages. - Limits. 100 subscriptions per connection, 64 KB per broadcast payload and 8 KB per presence state.
- Reconnects. Backoff with jitter runs from 1 s up to 60 s, with a 10 s floor after a rate-limit close. The client doesn't reconnect after close codes
1000,1001or4001.
Edge functions
const { data, error } = await orbit.functions.invoke<{ ok: boolean }>('send-welcome', {
body: { userId },
});
console.log(data?.data); // the function's JSON responseinvoke(name, { method?, body?, headers? }) sends POST by default and calls /api/projects/<slug>/functions/v1/<name>. On success it returns { data, status, headers }. A non-2xx response comes back as error with its status.
From version 0.7.0:
statusandheadersare the function's real response status and headers.- The body is parsed by content type: JSON becomes an object,
text/*a string, and anything else aBlob. - On a non-2xx response,
error.detailsholds the parsed body. - While a user is signed in, the call carries their access token, so the function runs with
ctx.userset. You don't need to add headers yourself.
In 0.6.0, status was always 200, headers was empty and a non-JSON body gave data: null. To run a function as the user in 0.6.0, pass the headers yourself: x-api-key set to the anon key and Authorization: Bearer <access token>.
See Edge functions for writing handlers.
Analytics
orbit.analytics.identify(user.id, { plan: 'pro' });
orbit.analytics.screen('Checkout');
orbit.analytics.track('purchase', { amount: 49 });
orbit.analytics.crash(new Error('Payment failed'));
await orbit.analytics.flush(); // optional; events also flush automatically| Method | Event sent |
|---|---|
identify(userId, traits?) | identify. Later events carry user_id. |
track(event, properties?) | event |
screen(name, properties?) | screen_view with properties.screen |
crash(error, properties?) | crash with the message and stack. From version 0.7.0, sent as error_type (the error's name), error_message and stack_trace, the fields crash grouping reads. |
flush() | Sends the queue now. Returns { accepted, rejected, errors }. |
Events are queued and sent to POST /api/analytics/events within 5 seconds of being queued, or immediately once 50 are waiting. In the browser, anonymous_id is kept in localStorage, so returning visitors aren't counted as new users. The session id rotates after 30 minutes of inactivity. Requests authenticate with the API key only (never the user's token), plus analyticsToken when one is set. See Analytics.
From version 0.7.0:
- A batch that fails with a network error, a timeout,
429or a5xxgoes back on the queue and is retried on the next flush. The queue keeps at most the newest 1,000 events. - A batch the API rejects with another
4xxis dropped, and the SDK logs aconsole.warn. An explicitflush()returns the error instead. - In the browser, the queue is flushed with
fetchkeepalivewhen the page is hidden (visibilitychange) or unloaded (pagehide), so the last events aren't lost.
Admin namespaces
jobs, env and logs map to Studio admin routes, which only accept a Studio admin access token. A project anon or service-role key can never call them. From version 0.7.0 they are deprecated: every method returns an error with code ADMIN_ONLY without sending a request. In 0.6.0 they sent the request and got 401. Manage jobs, environment variables and logs in Studio, with the CLI or with the MCP server.
Error handling
Every async method resolves to { data, error } and doesn't throw on API errors:
const { data, error } = await orbit.db.from('posts').select();
if (error) {
console.error(error.code, error.status, error.message);
return;
}error has the shape { message, code?, status?, details? }. status is the HTTP status and code is the API's error code when it sends one. When the API sends a list of validation messages, they're joined with ; .
From version 0.7.0, details holds the parsed error body for every non-2xx API response. For example, a 402 with code QUOTA_EXCEEDED has quota, plan, limit and usage in details, and a 403 with code PLAN_REQUIRED has required_plan and plan. See Plans & billing.
The SDK sets these codes itself:
| Code | Meaning |
|---|---|
TIMEOUT | The request exceeded timeout |
NETWORK_ERROR | fetch failed (offline, CORS, DNS) |
NO_SESSION | A method that needs a signed-in user was called without a session |
NOT_FOUND | single() matched no rows |
VALIDATION_ERROR | Invalid storage path |
UPLOAD_ERROR / DOWNLOAD_ERROR | Storage transfer failed before a response |
ADMIN_ONLY | A deprecated jobs, env or logs method was called (from version 0.7.0) |
To use try/catch instead, wrap any result in unwrap(). It returns data, or throws an OrbitNestError that carries code and status:
import { unwrap, OrbitNestError } from '@orbitneststudio/js';
try {
const { rows } = unwrap(await orbit.db.from('posts').select());
} catch (e) {
if (e instanceof OrbitNestError) console.error(e.code, e.status, e.message);
}A few calls throw synchronously because the failure is a programming error:
createClientwith a missing or invalid key- Query-builder identifiers that aren't plain names
nearest()with a non-numeric vectorgetPublicUrl()with a path that contains..or a null byte