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
npm install @orbitneststudio/nodeThe 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.
| Key | Use it for |
|---|---|
| Service-role key | Trusted server code. Bypasses row-level security, can run DDL, and is required for migrations, bulk inserts and bucket management. |
| Anon key | Server 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
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.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | A project API 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. |
migrationsDir | string | migrations | Folder of *.sql migration files, resolved from the process working directory |
analyticsToken | string | none | Ingest-only analytics token, sent as x-analytics-token |
realtimeOptions | { WebSocket?, userJwt?, accessToken? } | globalThis.WebSocket | WebSocket 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.
| Property | What it does |
|---|---|
db | Query builder, raw SQL, row helpers and RLS helpers |
migrations | Versioned SQL migration runner |
storage | storage.from(bucket) for upload, download, list, remove and public URLs |
channel(name) / realtime | Postgres changes, broadcast and presence |
functions | Invoke edge functions |
auth | End-user auth endpoints, with an in-memory session |
analytics | Batched event tracking |
jobs, env, logs | Deprecated. 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.
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.
| Method | SQL |
|---|---|
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.
| Method | Returns |
|---|---|
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:
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.
Vector search
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
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
| Method | Notes |
|---|---|
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
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:
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.
migrations/
001_init.sql
002_add_posts.sqlFile 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.
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL
);
-- migrate:down
DROP TABLE posts;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);
}| Method | Description |
|---|---|
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 CONCURRENTLYisn't supported. - Applied migrations are immutable. If a file changes after it was applied (a checksum mismatch),
runAll()andrunOne()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.resultholds the partial result. - From version 0.13.0, if the database already has a
migrationstable 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
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' } });| Method | Notes |
|---|---|
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:
contentTypesets the stored MIME type. Without it, the SDK uses theBlob's type, then guesses from the file extension, then falls back toapplication/octet-stream. That means aBufferof a.pngis no longer stored asapplication/octet-stream.cacheControlis deprecated and ignored.list(),remove()andcreateBucket()return the payload directly. In 0.12.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.
Realtime
Node.js 22 and later have a global WebSocket. On older versions, pass one from the ws package:
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
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.
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.
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);| Method | Endpoint |
|---|---|
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
orbit.analytics.identify(userId, { plan: 'pro' });
orbit.analytics.track('invoice_paid', { amount: 49 });
await orbit.analytics.flush(); // flush before the process exitsidentify, 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.
| Namespace | Methods |
|---|---|
jobs | create, list, get(name), update(name, opts), delete(name), trigger(name), getRuns(name, { limit? }) |
env | list, get(name), set(name, value, opts?), create(name, value, opts?), bulkCreate(vars), delete(name) |
logs | getLogs, getDatabaseLogs, getSlowQueryLogs, getAuthLogs, getAuthFailures, getEdgeFunctionLogs() |
Error handling
Every async method resolves to { data, error }. API errors are returned, not thrown:
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:
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:
createClientwith a bad key- Unsafe query-builder identifiers
nearest()with a non-numeric vectorgetPublicUrl()with an invalid path- Every migration method, which throws
MigrationError