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

Terminal
npm install @orbitneststudio/js

The package ships ESM, CommonJS and a minified IIFE bundle for <script> tags. The IIFE build exposes a global named OrbitNest:

index.html
<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 runsKey to use
Browser, mobile web, anything a user can inspectAnon key
Your own server, a build step, CIService-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.

AreaAnon key, nobody signed inAnon 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 anonSame, as the user, with RLSUPDATE, DELETE, DDL
Row helpersinsert(), getTableData()Also update(), delete(), bulkUpdate() and bulkDelete(). All of these run as the user, with RLS.bulkInsert()
RLS helpers——listPolicies(), enableRls(), disableRls(), createPolicy(), deletePolicy()
Storagelist() 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 functionsinvoke()invoke(), with ctx.user set—
RealtimeAll channel featuresEvents 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

orbit.ts
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.

OptionTypeDefaultDescription
apiKeystringrequiredYour project's anon key (a JWT).
projectSlugstringDecoded from apiKeyOverride the project slug.
baseUrlstringhttps://api.orbitnest.ioAPI origin. A trailing slash is removed.
timeoutnumber30000Per-request timeout in milliseconds. Storage uploads and downloads use a fixed 30-second timeout.
storageStorageAdapterIn-memoryWhere the auth session is persisted. See Session persistence.
analyticsTokenstringnoneIngest-only analytics token, sent as x-analytics-token on analytics requests.
realtimeOptions{ WebSocket?, userJwt?, accessToken? }globalThis.WebSocketA 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:

PropertyWhat it does
dbQuery builder, raw SQL, row helpers and RLS helpers
authEmail and password, email OTP sign-up, SMS OTP, TOTP MFA, passkeys
storagestorage.from(bucket) for upload, download, list, remove and public URLs
channel(name) / realtimePostgres changes, broadcast and presence over one WebSocket
functionsInvoke edge functions
analyticsBatched event tracking
jobs, env, logsDeprecated. 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:

TypeScript
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.
  • localStorageAdapter looks up localStorage on 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.

TypeScript
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.

MethodSQL
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.

MethodBehavior
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:

MethodReturns
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:

TypeScript
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 key

update 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]).

TypeScript
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.

nearest(column, vector, { metric?, limit? }) orders rows by pgvector distance from vector, closest first. You can combine it with filters:

TypeScript
await orbit.db
  .from('documents')
  .eq('workspace_id', workspaceId)
  .nearest('embedding', queryEmbedding, { metric: 'cosine', limit: 5 })
  .select('id, title');
metricOperator
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

TypeScript
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

MethodKeyNotes
listTables()anon or servicestring[] of table names
getTableMetadata(table)anon or serviceFrom 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 serviceFrom 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 serviceReturns the inserted row
update(table, id, data) / delete(table, id)user or serviceMatches on the id column. update returns the updated row.
bulkInsert(table, rows)serviceReturns the inserted rows
bulkUpdate(table, [{ where, data }]) / bulkDelete(table, [where])user or servicewhere 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)serviceFrom version 0.7.0, returns RlsPolicy[] with name, command, definition, check and roles.
createPolicy(table, policy) / deletePolicy(table, name)servicepolicy 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.

TypeScript
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:

TypeScript
await orbit.auth.signUp({ email, password, metadata: { full_name: 'Ada' } });

const { data: session, error } = await orbit.auth.verifySignUp({ email, code: '1234' });

Sign in:

TypeScript
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

MethodDescription
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 email or password with updateUser needs current_password. Accounts without a password (passkey, SMS) need a session signed in within the last 10 minutes instead. Otherwise the API returns 401 with code REAUTH_REQUIRED. An email that another user already has returns 409. A new email is not applied yet: the returned user has new_email, and a code is sent to that address for verifyEmailChange. See Change the email address.
  • metadata is merged into the user's user_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 a 401 from the refresh endpoint clears the session. Other errors, such as 402, 403 or 429, leave it in place. In 0.6.0 nothing refreshed automatically, so you had to call refreshSession() 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, onAuthStateChange reports session changes. In 0.6.0, read getSession() or getUser() after each auth call.

MFA (TOTP)

Enrollment needs a signed-in user:

TypeScript
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.
MethodDescription
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:

TypeScript
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 });
MethodSession requiredDescription
signUpWithPasskeyOptions({ email, user_metadata?, device_name? })NoReturns 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? })NoCreates the user and stores the session
signInWithPasskeyOptions({ identifier? })No
signInWithPasskeyVerify({ challenge_id, assertion })NoStores the session
registerPasskeyOptions({ device_name? })YesAdds a passkey to the current user
registerPasskeyVerify({ challenge_id, attestation, device_name? })YesReturns the PasskeyDevice
listPasskeys() / renamePasskey(id, name) / revokePasskey(id)YesManage the user's passkeys

Storage

TypeScript
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;
MethodReturnsNotes
upload(path, file, { upsert?, contentType? })StorageFilefile 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 listThe 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:

  • contentType sets the stored MIME type. Without it, the SDK uses the Blob's own type, then guesses from the file extension for common types, then falls back to application/octet-stream. The old cacheControl option is deprecated and ignored.
  • createBucket accepts { public }.
  • list(), remove() and createBucket() return the payload directly. In 0.6.0 they returned the API's { data, error } envelope as data, so the payload sat at data.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

TypeScript
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
MethodDescription
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

TypeScript
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.userJwt turns this off and always wins. To change that fixed token later, call orbit.realtime.setAuth(token). In 0.6.0, pass realtimeOptions: { userJwt: session.access_token } yourself.
  • Broadcast rules. You must subscribe to a broadcast channel (register an on('broadcast', …) handler) before you send(). 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, 1001 or 4001.

Edge functions

TypeScript
const { data, error } = await orbit.functions.invoke<{ ok: boolean }>('send-welcome', {
  body: { userId },
});
console.log(data?.data); // the function's JSON response

invoke(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:

  • status and headers are 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 a Blob.
  • On a non-2xx response, error.details holds the parsed body.
  • While a user is signed in, the call carries their access token, so the function runs with ctx.user set. 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

TypeScript
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
MethodEvent 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, 429 or a 5xx goes 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 4xx is dropped, and the SDK logs a console.warn. An explicit flush() returns the error instead.
  • In the browser, the queue is flushed with fetch keepalive when 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:

TypeScript
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:

CodeMeaning
TIMEOUTThe request exceeded timeout
NETWORK_ERRORfetch failed (offline, CORS, DNS)
NO_SESSIONA method that needs a signed-in user was called without a session
NOT_FOUNDsingle() matched no rows
VALIDATION_ERRORInvalid storage path
UPLOAD_ERROR / DOWNLOAD_ERRORStorage transfer failed before a response
ADMIN_ONLYA 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:

TypeScript
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:

  • createClient with a missing or invalid key
  • Query-builder identifiers that aren't plain names
  • nearest() with a non-numeric vector
  • getPublicUrl() with a path that contains .. or a null byte