SDKs & integrations

Node.js SDK

Use @orbitneststudio/node on your server for database queries, migrations, storage, realtime, auth, edge functions and analytics.

@orbitneststudio/node is the server-side SDK for OrbitNest. Use it in API servers, scripts, CI jobs and build steps. It has the same API as the JavaScript SDK, plus a filesystem-backed SQL migration runner. This page describes version 0.13.0, which requires Node.js 18 or later. Version 0.13.0 isn't on npm yet, so npm install still gets 0.12.0. Behavior that is new in 0.13.0 is marked "from version 0.13.0".

Install

Terminal
npm install @orbitneststudio/node

The package ships both ESM and CommonJS builds.

Keys

Copy your keys from your project's API Keys page in Studio. See API keys & JWT for details.

KeyUse it for
Service-role keyTrusted server code. Bypasses row-level security, can run DDL, and is required for migrations, bulk inserts and bucket management.
Anon keyServer code that should be limited the same way a browser client is: SELECT and INSERT only, with RLS policies applied.

Keep service keys on the server

Load the service-role key from an environment variable or a secret manager. Never commit it, log it, or send it to a browser or mobile app.

Every database, storage and function request sends the API key in the apikey header. From version 0.13.0, after orbit.auth signs a user in, Authorization carries that user's access token (Bearer <user token>), and the key when nobody is signed in. The API then applies RLS as that user, and edge functions see them as ctx.user. A service-role client that signs a user in therefore acts as that user, storage included. Use a separate client for service-role work. In 0.12.0 the user token was never sent. The rules for what a signed-in user can do are in What the anon key can do.

Create a client

orbit.ts
import { createClient } from '@orbitneststudio/node';

export const orbit = createClient({
  apiKey: process.env.ORBITNEST_SERVICE_KEY!,
});

apiKey is the only required option. The SDK decodes the project slug from the key's project_slug claim.

OptionTypeDefaultDescription
apiKeystringrequiredA project API 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.
migrationsDirstringmigrationsFolder of *.sql migration files, resolved from the process working directory
analyticsTokenstringnoneIngest-only analytics token, sent as x-analytics-token
realtimeOptions{ WebSocket?, userJwt?, accessToken? }globalThis.WebSocketWebSocket constructor (required on Node.js below 22). From version 0.13.0 the socket follows the signed-in user. A fixed userJwt overrides that.

createClient throws when apiKey is missing, is not a three-part JWT, or has no project_slug claim.

PropertyWhat it does
dbQuery builder, raw SQL, row helpers and RLS helpers
migrationsVersioned SQL migration runner
storagestorage.from(bucket) for upload, download, list, remove and public URLs
channel(name) / realtimePostgres changes, broadcast and presence
functionsInvoke edge functions
authEnd-user auth endpoints, with an in-memory session
analyticsBatched event tracking
jobs, env, logsDeprecated. See Admin namespaces.

Database

Query builder

orbit.db.from(table) compiles a chain into one parameterized SELECT. Values are always bound ($1, $2, …) and never interpolated.

TypeScript
const { data, error } = await orbit.db
  .from('posts')
  .eq('status', 'published')
  .gte('created_at', '2026-01-01')
  .order('created_at', { ascending: false })
  .range(0, 19)
  .select('id, title, created_at');

if (error) throw new Error(error.message);
for (const row of data.rows) console.log(row.title);

Filters. Filters combine with AND.

MethodSQL
eq(column, value) / neq(column, value)= / !=
gt, gte, lt, lte (column, value)>, >=, <, <=
like(column, pattern) / ilike(column, pattern)LIKE / ILIKE
in(column, values: unknown[])IN (...). An empty array matches nothing.
is(column, value: null | boolean)IS NULL for null, otherwise = value
textSearch(column, query, { type?, config? })Full-text search. type is websearch (the default), plain or phrase. config defaults to english.
nearest(column, vector, { metric?, limit? })pgvector ordering, closest first. metric is l2 (the default, <->), cosine (<=>) or ip (<#>).

Ordering and pagination. Use order(column, { ascending? }) (ascending by default, and you can call it more than once), limit(n), range(from, to) (inclusive), or page(n, size = 20) (1-based). orderBy(column, 'ASC' | 'DESC') is a deprecated alias.

Running the query.

MethodReturns
select<T>(columns = '*'){ rows: T[], total }. total is the number of rows returned.
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

columns is * or a comma-separated list of plain column names, with no aliases, casts or joins. Table and column names must match ^[a-zA-Z_][a-zA-Z0-9_]*$, or the builder throws.

Writes. These ignore filters on the chain. update and delete match one row by its id column, and need the service-role key or a signed-in user:

TypeScript
const { data: post } = await orbit.db.from('posts').insert({ title: 'Hello' });
await orbit.db.from('posts').update(post.id, { title: 'Edited' });
await orbit.db.from('posts').delete(post.id);

The builder has no upsert, or filter or rpc. Use raw SQL for those.

TypeScript
const { data } = await orbit.db
  .from('documents')
  .nearest('embedding', embedding, { metric: 'cosine', limit: 5 })
  .select('id, title');

The project needs the vector extension, and the column must be of type vector. See Database.

Raw SQL

TypeScript
const { data, error } = await orbit.db.query(
  `INSERT INTO posts (slug, title) VALUES ($1, $2)
   ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title
   RETURNING id`,
  [slug, title],
);
// data: { rows, rowCount, fields: [{ name, dataType }] }

db.query(sql, params?) runs any statement with the service-role key. With the anon key, statements 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. Even with the service-role key, statements that drop, truncate, alter or DELETE FROM an auth_* table are refused with 403. That includes dynamic SQL in DO blocks that loops over the table catalog without excluding auth_* tables. Always pass values through params.

Table and RLS helpers

MethodNotes
listTables()string[]
getTableMetadata(table)From version 0.13.0, returns TableMetadata[] with table_name, rls_enabled and columns (column_name, data_type, is_nullable, column_default)
getTableData(table, { page?, limit?, sortBy?, sortOrder? })From version 0.13.0, returns { rows, total }. In 0.12.0, data was the raw API payload (data.data and data.total_rows).
insert(table, row)Returns the inserted row
update(table, id, data) / delete(table, id)Match on the id column. Service-role key or a signed-in user. update returns the updated row.
bulkInsert(table, rows)Service-role key. Returns the inserted rows.
bulkUpdate(table, [{ where, data }]) / bulkDelete(table, [where])where is an object of equality conditions. Service-role key or a signed-in user. From version 0.13.0, they return { updated } and { deleted }, the number of rows affected.
enableRls(table) / disableRls(table)Service-role key
listPolicies(table)From version 0.13.0, returns RlsPolicy[] with name, command, definition, check and roles
createPolicy(table, policy) / deletePolicy(table, name)Service-role key. policy is an RlsPolicy: name, command, definition (the USING expression), and optional check and roles.

Generate types

scripts/gen-types.ts
import { writeFileSync } from 'node:fs';
import { createClient, generateTypes } from '@orbitneststudio/node';

const orbit = createClient({ apiKey: process.env.ORBITNEST_SERVICE_KEY! });
writeFileSync('orbitnest.types.ts', await generateTypes(orbit.db));

The generated file has one interface per table and a Database map. Tables matching /^(auth_|_)/ are skipped. Pass { tables: [...] } or { exclude: /regex/ } to change that. Use the types with the builder:

TypeScript
import type { Posts } from './orbitnest.types';

const { data } = await orbit.db.from('posts').eq('id', id).single<Posts>();

The CLI offers the same thing as orbitnest gen types.

Migrations

orbit.migrations applies ordered SQL files from migrationsDir and records each one in a migrations table in your project database. Migrations run DDL, so create the client with the service-role key.

text
migrations/
  001_init.sql
  002_add_posts.sql

File names must start with a number followed by _ or - and end in .sql. The number is the migration id, and files run in numeric order. To make a migration reversible, add a rollback section after a -- migrate:down line. From version 0.13.0, the marker must be the whole line: -- migrate:down, -- down or --- DOWN ---. A comment that only starts with "down", such as -- Down-sampled metrics, no longer splits the file.

migrations/002_add_posts.sql
CREATE TABLE posts (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title text NOT NULL
);

-- migrate:down
DROP TABLE posts;
scripts/migrate.ts
import { createClient, MigrationError } from '@orbitneststudio/node';

const orbit = createClient({
  apiKey: process.env.ORBITNEST_SERVICE_KEY!,
  migrationsDir: 'migrations',
});

try {
  const result = await orbit.migrations.runAll();
  console.log('ran', result.ran, 'skipped', result.skipped);
} catch (e) {
  if (e instanceof MigrationError) console.error(e.message, e.result?.failed);
  process.exit(1);
}
MethodDescription
runAll()Applies every pending migration in order. Stops at the first failure. Returns { ran, skipped, failed? }.
runOne(migrationId)Applies one migration, or does nothing if it's already applied
rollback(migrationId?)Runs the -- migrate:down section of the most recent applied migration, or of the given one, then removes its registry row. Returns { rolledBack }.
getStatus()[{ migrationId, name, checksum, status, applied, executedAt?, checksumMismatch }]. status is success, failed or pending.
createMigration(name)Writes the next NNN_name.sql file with an up section and a -- migrate:down section. It never overwrites an existing file. Synchronous.
seed(file = 'seed.sql')Runs a seed script in one transaction. Seeds aren't recorded, so make them re-runnable.
reset()Destructive. Drops every table in the public schema, including the registry, then re-applies all migrations. From version 0.13.0, tables whose names start with auth_, pg_ or _ are kept, the same as the CLI's db reset. Use it only on data you can lose.

Safety rules:

  • Each migration (or rollback section) runs inside one BEGIN … COMMIT. A failure rolls the whole file back, so scripts must be transaction-safe. For example, CREATE INDEX CONCURRENTLY isn't supported.
  • Applied migrations are immutable. If a file changes after it was applied (a checksum mismatch), runAll() and runOne() throw.
  • Runs never overlap within a process. Run migrations from one place, such as a single CI step.
  • Every failure throws MigrationError. When the failure happened mid-run, error.result holds the partial result.
  • From version 0.13.0, if the database already has a migrations table that OrbitNest didn't create, the runner stops with "A "migrations" table already exists but was not created by OrbitNest" instead of writing to it.

The engine is also exported as createMigrationRunner(db, dir), along with MigrationRunner, MigrationScanner, MigrationRegistry and MigrationExecutor. Use these for custom tooling. The CLI uses the same file format. See Migrations for the workflow.

Storage

TypeScript
import { readFile } from 'node:fs/promises';

const avatars = orbit.storage.from('avatars');

const { data: file, error } = await avatars.upload('users/42.png', await readFile('42.png'), {
  upsert: true,
});
const { data: blob } = await avatars.download('users/42.png');
const { data: url } = avatars.getPublicUrl('users/42.png', { transform: { width: 256, format: 'webp' } });
MethodNotes
upload(path, file: Blob | Buffer | ArrayBuffer | Uint8Array, { upsert?, contentType? })Returns a StorageFile. Maximum 50 MB. Needs the service-role key or a signed-in user.
download(path)Returns a Blob
list(prefix?, { limit? })The server defaults limit to 100
remove(paths)Returns { deleted, errors }. Needs the service-role key, or a signed-in user for their own files.
getPublicUrl(path, { transform? })Synchronous. Returns { data: { publicUrl } }, which works only for public buckets.
createBucket({ public? })Needs the service-role key. A new bucket is private unless you pass public: true. Leave public out to keep an existing bucket's visibility.

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 are rejected.

From version 0.13.0:

  • contentType sets the stored MIME type. Without it, the SDK uses the Blob's type, then guesses from the file extension, then falls back to application/octet-stream. That means a Buffer of a .png is no longer stored as application/octet-stream. cacheControl is deprecated and ignored.
  • list(), remove() and createBucket() return the payload directly. In 0.12.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.

Realtime

Node.js 22 and later have a global WebSocket. On older versions, pass one from the ws package:

TypeScript
import WebSocket from 'ws';
import { createClient } from '@orbitneststudio/node';

const orbit = createClient({
  apiKey: process.env.ORBITNEST_SERVICE_KEY!,
  realtimeOptions: { WebSocket },
});

orbit
  .channel('orders')
  .on('postgres_changes', { event: '*', table: 'orders', filter: { status: { eq: 'paid' } } },
    (e) => console.log(e.op, e.new ?? e.old))
  .subscribe((status, err) => console.log(status, err));

The channel API matches the browser SDK. It has on('postgres_changes' | 'broadcast', …), subscribe(cb?), send(event, payload), unsubscribe(), presence.track/untrack/onSync/onJoin/onLeave/getState, orbit.realtime.setAuth(token) and orbit.realtime.disconnect(). See Realtime in the JavaScript SDK for the full reference, including the 0.13.0 fixes to unsubscribe() and untrack().

A service-role connection receives every change on realtime-enabled tables. An anon connection does too, unless the socket carries a user JWT, in which case each event is re-checked against RLS as that user. From version 0.13.0, the socket uses the user that orbit.auth signed in and reconnects when the session changes. A fixed realtimeOptions.userJwt overrides that. The key travels in the WebSocket subprotocol and an Authorization header, never in the URL. See Realtime.

Edge functions

TypeScript
const { data, error } = await orbit.functions.invoke('send-digest', {
  method: 'POST',
  body: { since: '2026-10-01' },
});
console.log(data?.data);

invoke(name, { method = 'POST', body?, headers? }) calls /api/projects/<slug>/functions/v1/<name> and returns { data, status, headers }. Non-2xx responses come back as error with their status. A service-role key runs the function with the service_role project role.

From version 0.13.0, status and headers are the function's real ones. 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. In 0.12.0, status was always 200 and headers was empty. See Edge functions.

Auth

orbit.auth calls the same end-user endpoints as the browser SDK. Use it for server-side sign-in flows, tests and scripts. The session lives in memory for the life of the client, and there's no persistence adapter. From version 0.13.0, the access token is refreshed 30 seconds before it expires, and getAccessToken() and onAuthStateChange(cb) work as in the JavaScript SDK. In 0.12.0 nothing refreshed automatically.

TypeScript
import { isMfaChallenge } from '@orbitneststudio/node';

const { data, error } = await orbit.auth.signIn({ email, password });
if (isMfaChallenge(data)) await orbit.auth.verifyMfa(data.challenge_token, code);
const session = orbit.auth.getSession();

The available methods are:

  • Email and password: signUp, verifySignUp, signIn, verifyMfa, signOut, refreshSession
  • Password reset: resetPasswordForEmail, updatePassword
  • SMS: signInWithSms, verifySmsOtp
  • Session: getSession, getUser, setSession, getAccessToken, onAuthStateChange, getProfile, updateUser({ email?, password?, current_password?, metadata? }), verifyEmailChange(code), deleteUser
  • MFA: enrollMfaTotp(friendlyName?, code?), verifyMfaEnrollment, listMfaFactors, unenrollMfa(factorId, code?), regenerateMfaRecoveryCodes(code?)
  • Passkeys: the passkey options and verify methods

Signatures match Authentication in the JavaScript SDK, including the current_password and MFA code rules. Passkey ceremonies run in a browser or on a device, so on a server you only relay the options and verify payloads. An email change is confirmed with a code sent to the new address. See Change the email address.

Manage users (service role)

orbit.auth.admin manages any user of the project from your server. Create the client with the service role key. Other keys get 401 or 403. It is only in the Node.js SDK.

TypeScript
const admin = createClient({ apiKey: process.env.ORBITNEST_SERVICE_ROLE_KEY! });

const { data: page } = await admin.auth.admin.listUsers({ page: 1, limit: 50, search: 'ada' });
const { data: user } = await admin.auth.admin.createUser({ email: 'ada@example.com', password: 'a-long-passphrase', email_confirm: true });
await admin.auth.admin.updateUserById(user!.id, { banned_until: '2030-01-01T00:00:00Z' }); // also signs them out
await admin.auth.admin.deleteUser(user!.id);
MethodEndpoint
listUsers({ page?, limit?, search? })GET /auth/admin/users → { users, total, page, limit, total_pages }
getUserById(id)GET /auth/admin/users/:id
createUser({ email, password?, email_confirm?, user_metadata?, app_metadata? })POST /auth/admin/users
updateUserById(id, { email?, password?, email_confirm?, user_metadata?, app_metadata?, banned_until? })PUT /auth/admin/users/:id (banned_until: null unbans)
deleteUser(id)DELETE /auth/admin/users/:id
getStats()GET /auth/admin/stats

auth.admin and verifyEmailChange are new in the unreleased 0.13.0. See Manage users from your server for the rules.

Analytics

TypeScript
orbit.analytics.identify(userId, { plan: 'pro' });
orbit.analytics.track('invoice_paid', { amount: 49 });
await orbit.analytics.flush(); // flush before the process exits

identify, track, screen and crash queue events. The queue flushes within 5 seconds of an event being queued, or immediately once 50 are waiting. On a server there's no localStorage, so anonymous_id and session_id are random per client instance and platform is node. Call flush() before a script or serverless handler exits, or queued events are lost. From version 0.13.0, a batch that fails with a network error, timeout, 429 or 5xx is put back on the queue (up to 1,000 events) and retried on the next flush, and crash() sends error_type, error_message and stack_trace. See Analytics.

Admin namespaces

jobs, env and logs map to Studio admin routes, which accept only a Studio admin access token. A project anon or service-role key can never call them. From version 0.13.0 they are deprecated: every method returns an error with code ADMIN_ONLY without sending a request. In 0.12.0 they sent the request and got 401. To manage jobs, environment variables and logs, use Studio, the CLI or the MCP server.

NamespaceMethods
jobscreate, list, get(name), update(name, opts), delete(name), trigger(name), getRuns(name, { limit? })
envlist, get(name), set(name, value, opts?), create(name, value, opts?), bulkCreate(vars), delete(name)
logsgetLogs, getDatabaseLogs, getSlowQueryLogs, getAuthLogs, getAuthFailures, getEdgeFunctionLogs()

Error handling

Every async method resolves to { data, error }. API errors are returned, not thrown:

TypeScript
const { data, error } = await orbit.db.from('posts').select();
if (error) {
  console.error(error.code, error.status, error.message);
}

error is { message, code?, status?, details? }. From version 0.13.0, details holds the parsed error body of every non-2xx API response, such as quota, plan, limit and usage on a 402 with code QUOTA_EXCEEDED. The SDK's own codes are TIMEOUT, NETWORK_ERROR, NO_SESSION, NOT_FOUND (from single()), VALIDATION_ERROR (bad storage path), UPLOAD_ERROR, DOWNLOAD_ERROR and ADMIN_ONLY (deprecated namespaces, from 0.13.0).

For try/catch, wrap a result in unwrap(). It returns data, or throws an OrbitNestError with code and status:

TypeScript
import { unwrap, OrbitNestError } from '@orbitneststudio/node';

try {
  const { rows } = unwrap(await orbit.db.query('SELECT count(*) FROM posts'));
} catch (e) {
  if (e instanceof OrbitNestError) console.error(e.code, e.status, e.message);
}

Some calls throw directly:

  • createClient with a bad key
  • Unsafe query-builder identifiers
  • nearest() with a non-numeric vector
  • getPublicUrl() with an invalid path
  • Every migration method, which throws MigrationError