@onyx.dev/onyx-database
v2.8.2
Published
TypeScript client SDK for Onyx Database
Readme
@onyx.dev/onyx-database
TypeScript client SDK for Onyx Cloud Database — a zero-dependency, strict-typed, builder-pattern API for querying and persisting data in Onyx from Node.js or edge runtimes like Cloudflare Workers. Ships ESM & CJS, includes a credential resolver, and an optional schema code generator that produces table-safe types and a tables enum.
- Website: https://onyx.dev/
- Cloud Console: https://cloud.onyx.dev
- Docs hub: https://onyx.dev/documentation/
- Cloud API docs: https://onyx.dev/documentation/api-documentation/
- API Reference: ./docs
- Quality: 100% unit test coverage enforced in CI
Table of contents
- Getting started
- Install
- Initialize the client
- MessagePack entity transport
- Onyx AI (chat & models)
- Published model predictions
- Generate schema types
- Query helpers
- Native vector-managed and bounded candidate search
- Examples
- Error handling
- HTTP retries
- Onyx CLI
- Runtime & bundlers
- Release workflow
- Related links
- Security
- License
Getting started (Cloud ➜ keys ➜ connect)
Sign up & create resources at https://cloud.onyx.dev
Create an Organization, then a Database, define your Schema (e.g.,User,Role,Permission), and create API Keys.Note your connection parameters:
baseUrl(e.g.,https://api.onyx.dev)databaseIdapiKeyapiSecret
Install the SDK in your project:
npm i @onyx.dev/onyx-databaseInitialize the client using env vars or explicit config.
Supports Node.js 18+ and Cloudflare Workers.
Install
npm i @onyx.dev/onyx-databaseThe package is dual-module (ESM + CJS) and has no runtime or peer dependencies.
CLI tooling and schema codegen now live in the dedicated Onyx CLI repo: https://github.com/OnyxDevTools/onyx-cli. Install it via the official install script or Homebrew (macOS):
curl -fsSL https://raw.githubusercontent.com/OnyxDevTools/onyx-cli/main/scripts/install.sh | bash
# macOS (Homebrew)
brew tap OnyxDevTools/onyx-cli
brew install onyx-cliInitialize the client
This SDK resolves credentials automatically using the chain explicit config ➜ environment variables ➜ ONYX_CONFIG_PATH file ➜ project config file ➜ home profile (Node.js only for file-based sources). Call onyx.init({ databaseId: 'database-id' }) to target a specific database, or omit the databaseId to use the default. You can also pass credentials directly via config.
Reliability defaults (read this):
- Retries: GET/query calls auto-retry up to 3 times with Fibonacci backoff starting at 300ms (honors
Retry-After); writes never retry. - Config cache: Resolved config is cached per
${databaseId}-${apiKey}-${wireFormat}for 5 minutes; tune withttl, clear viaonyx.clearCacheConfig().
Option A) Environment variables (recommended for production)
Set these environment variables for your database:
| Variable | Purpose | Default when unset |
| --- | --- | --- |
| ONYX_DATABASE_ID | Optional database scope for env credentials; omit to use file/profile/default context | optional |
| ONYX_DATABASE_BASE_URL | Base URL for DB API | https://api.onyx.dev |
| ONYX_DATABASE_API_KEY | API key | required |
| ONYX_DATABASE_API_SECRET | API secret | required |
| ONYX_AI_BASE_URL | Base URL for AI endpoints | https://ai.onyx.dev |
| ONYX_DEFAULT_MODEL | Model used by db.chat() shorthand | onyx |
| ONYX_CONFIG_PATH | Path to JSON credentials file (Node only; ignored on edge) | unset (falls back to env ➜ project file ➜ home profile) |
| ONYX_DEBUG | Enable HTTP + config debug logging | off |
| ONYX_STREAM_DEBUG | Enable streaming debug logs | off |
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init({ databaseId: 'YOUR_DATABASE_ID' }); // uses env when ID matches
// credentials are cached for 5 minutes by defaultOption B) Explicit config
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init({
baseUrl: 'https://api.onyx.dev',
aiBaseUrl: 'https://ai.onyx.dev', // optional: override AI base path
defaultModel: 'onyx', // optional: shorthand `db.chat()` model
databaseId: 'YOUR_DATABASE_ID',
apiKey: 'YOUR_KEY',
apiSecret: 'YOUR_SECRET',
partition: 'tenantA',
wireFormat: 'json', // optional: opt out of MessagePack for entity routes
requestLoggingEnabled: true, // logs HTTP requests
responseLoggingEnabled: true, // logs HTTP responses
});The partition option sets a default partition for table-scoped queries,
findById, and deletes by primary key; database-wide db.search(...) omits it.
Save operations use the partition field on the entity
itself. Enable requestLoggingEnabled to log each request and its body to the
console. Enable responseLoggingEnabled to log responses and bodies. Setting
the ONYX_DEBUG=true environment variable enables both request and response
logging even if these flags are not set. It also logs the source of resolved
credentials (explicit config, env vars, config path file, project file, or home profile).
MessagePack entity transport
MessagePack is the default wire format for entity routes. To use JSON with a
legacy deployment that does not support the zero-dependency binary transport,
set wireFormat: 'json':
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init({
databaseId: 'YOUR_DATABASE_ID',
apiKey: 'YOUR_KEY',
apiSecret: 'YOUR_SECRET',
wireFormat: 'json',
});
await db.save('User', {
id: 'user-1',
profile: { displayName: 'Ada', roles: ['admin'] },
});
const users = await db.from('User').resolve('profile').list();By default, MessagePack applies only to entity saves, reads, deletes, queries,
and query streams. Documents, schemas, secrets, AI, and model-builder requests
continue to use JSON. The client sends application/vnd.msgpack and advertises
JSON as a lower-priority response fallback; it always decodes the actual
response Content-Type, so JSON errors and fallback responses continue to work.
Nested objects, arrays, strings, booleans, nulls, JavaScript-safe numbers, and
signed 64-bit bigint values are supported. Dates are normalized to ISO strings
as they are for JSON. Integer number inputs must remain within JavaScript's safe
range; use bigint for larger signed values. Decoded integers outside the safe
range are returned as bigint, while unsigned wire values above
9223372036854775807 are rejected. Because JSON cannot serialize bigint, use
these values only with MessagePack (the default), not an explicit
wireFormat: 'json' override.
Object properties containing undefined are omitted, while undefined array
entries and non-finite numbers become null, matching JSON.stringify.
MessagePack streams contain concatenated, self-delimiting values rather than newline framing. The client tolerates values split across network chunks, skips the server's optional initial nil flush frame, and can consume a JSON-lines fallback when indicated by the response content type.
When supplying a custom fetch, request bodies may be string | Uint8Array.
Its response object must implement arrayBuffer() when it returns
application/vnd.msgpack; JSON-only fetch mocks only need text() when the
client is explicitly configured with wireFormat: 'json'. The client does not
automatically retry MessagePack mutations as JSON; configure JSON explicitly
when targeting an Onyx Cloud deployment without MessagePack support.
Option C) Node-only config files
Set ONYX_CONFIG_PATH to a JSON file containing your credentials. This file is checked after environment variables and before project and home files. When unset, the resolver checks for JSON files matching the OnyxConfig shape in the following order:
./onyx-database-<databaseId>.json./onyx-database.json~/.onyx/onyx-database-<databaseId>.json~/.onyx/onyx-database.json~/onyx-database.json
These files are ignored in non-Node runtimes like Cloudflare Workers.
Edge / RSC usage (Next.js, Cloudflare Workers)
For edge runtimes (Next.js Edge/RSC, Cloudflare Workers), import the edge entry. It avoids Node-only imports and only resolves credentials from environment variables or explicit config.
import { onyx } from '@onyx.dev/onyx-database/edge';
const db = onyx.init(); // uses env vars in edge runtimesFile-based config (ONYX_CONFIG_PATH, project files, home profiles) is not available in edge runtimes. If you need file-based config, use the Node entry instead.
Cloudflare Worker example:
import { onyx } from '@onyx.dev/onyx-database/edge';
export default {
async fetch(_request: Request, env: Record<string, string>) {
const db = onyx.init({
baseUrl: env.ONYX_DATABASE_BASE_URL,
databaseId: env.ONYX_DATABASE_ID,
apiKey: env.ONYX_DATABASE_API_KEY,
apiSecret: env.ONYX_DATABASE_API_SECRET,
});
return Response.json({ ok: true });
},
};Connection & config caching
Calling onyx.init() returns a lightweight client. Configuration is resolved once
and cached per ${databaseId}-${apiKey}-${wireFormat} tuple for 5 minutes to avoid repeated
env/file lookups (override with ttl or reset via onyx.clearCacheConfig()).
Each database instance keeps a single internal HttpClient. Requests use the
runtime's global fetch, which already reuses connections and pools them for
keep‑alive. Reuse the returned db for multiple operations; extra SDK‑level
connection pooling generally isn't necessary unless you create many short‑lived
clients.
Typed vs untyped init
onyx.init() is generic. Omit the type for quick scripts; add your generated schema type for full safety.
// Untyped: flexible, no compile-time field checks
const db = onyx.init();
const user = await db.from('User').findById('abc'); // inferred as any/unknown// Typed: import generated schema to get autocomplete and validation
import type { OnyxSchema as Schema } from './onyx/types';
const db = onyx.init<Schema>();
const user = await db.from('User').findById('abc'); // inferred as Schema['User']
// user.emali -> TypeScript errorOnyx AI (chat & models)
AI endpoints are OpenAI-compatible and use the same credentials as database calls. Use db.ai for chat, models, and script approvals; db.chat()/db.chat('...') remain supported as equivalent entrypoints. A shorthand db.chat('content') call is available and uses config.defaultModel (defaults to onyx). The AI base URL defaults to https://ai.onyx.dev and can be overridden with aiBaseUrl (or ONYX_AI_BASE_URL). The databaseId query param is optional; when omitted, the configured databaseId is used for grounding and billing.
Chat completions
Examples: examples/ai/chat.ts, examples/ai/chat-stream.ts.
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
const quick = await db.chat('Reply with exactly one short greeting sentence.'); // returns first message content
const completion = await db.ai.chat({
model: 'onyx-chat',
messages: [{ role: 'user', content: 'Summarize last week’s traffic.' }],
});
console.log(completion.choices[0]?.message?.content);
// Override defaults (model/role/temperature/stream) in shorthand form
const custom = await db.chat('List three colors.', {
model: 'onyx-chat',
role: 'user',
temperature: 0.2,
stream: false, // set raw: true to receive full completion response instead of the first message content
});Streaming works as an async iterable:
const stream = await db.ai.chat({
model: 'onyx-chat',
stream: true,
messages: [{ role: 'user', content: 'Write a short onboarding checklist.' }],
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
// stream.cancel() is available if you need to stop earlyTool calls mirror the ChatGPT TypeScript client:
const prompt = {
model: 'onyx-chat',
messages: [{ role: 'user', content: 'Find revenue for ACME in 2023.' }],
tools: [
{
type: 'function',
function: {
name: 'get_revenue',
description: 'Fetch revenue for a company and year',
parameters: {
type: 'object',
properties: {
company: { type: 'string' },
year: { type: 'number' },
},
required: ['company', 'year'],
},
},
},
],
};
const first = await db.ai.chat(prompt);
const toolCall = first.choices[0]?.message?.tool_calls?.[0];
if (toolCall) {
const toolResult = await getRevenue(JSON.parse(toolCall.function.arguments)); // your impl
const followup = await db.ai.chat({
model: prompt.model,
messages: [
...prompt.messages,
first.choices[0].message,
{ role: 'tool', tool_call_id: toolCall.id ?? '', content: JSON.stringify(toolResult) },
],
});
console.log(followup.choices[0]?.message?.content);
}Model metadata
Example: examples/ai/models.ts.
const models = await db.ai.getModels();
const chatModel = await db.ai.getModel('onyx-chat');Script mutation approvals
const approval = await db.ai.requestScriptApproval({
script: "db.save({ id: 'u1', email: '[email protected]' })",
});
if (approval.requiresApproval) {
console.log(`Requires approval until ${approval.expiresAtIso}`);
}Published model predictions
Use a published model key with raw input rows, or let a saved script provide the input rows for prediction.
const rawPrediction = await db.predict('churn-model', [
{ age: 42, country: 'US', usageScore: 0.87 },
{ age: 31, country: 'CA', usageScore: 0.42 },
]);
console.log(rawPrediction.predictions);
console.log(rawPrediction.rawPredictions);
const scriptPrediction = await db.predictFromScript(
'churn-model',
'score-active-users',
{ segment: 'enterprise' },
);
console.log(scriptPrediction.inputCount);Optional: generate TypeScript types from your schema
Use the Onyx CLI (onyx) from https://github.com/OnyxDevTools/onyx-cli to
emit per-table interfaces, a tables enum, and a Schema mapping for
compile-time safety and IntelliSense. Each generated interface also includes an
index signature so extra properties (for graph attachments in cascade saves)
don't trigger type errors.
Generate directly from the API (using the same credential resolver as init()):
onyx gen --ts --source api --out ./src/onyx/types.ts --name OnyxSchemaWith --source api, onyx gen calls the Schema API (same as onyx schema get) using the
standard config chain (env, project file, home profile).
Timestamp attributes are emitted as Date fields by default. When saving,
Date values are automatically serialized to ISO timestamp strings. Pass
--timestamps string to keep timestamps as ISO strings in generated types.
Or from a local schema file you export from the console:
onyx gen --ts --source file --schema ./onyx.schema.json --out ./src/onyx/types.ts --name OnyxSchemaRun it with no flags to use the defaults: onyx gen reads ./onyx.schema.json and writes to ./onyx/types.ts.
Manage schemas from the CLI
Publish or download schema JSON directly via API using the onyx schema helper:
# Publish ./onyx.schema.json with publish=true by default
onyx schema publish
# Overwrite ./onyx.schema.json with the remote schema
onyx schema get
# Print the remote schema without writing a file
onyx schema get --print
# Fetch only selected tables (prints to stdout; does not overwrite files)
onyx schema get --tables=User,Profile
# Example subset output
onyx schema get --tables=User,Profile
# {
# "tables": [
# {
# "name": "User",
# "attributes": [
# { "name": "id", "type": "string", "required": true },
# { "name": "email", "type": "string", "required": true }
# ]
# },
# {
# "name": "Profile",
# "attributes": [
# { "name": "id", "type": "string", "required": true },
# { "name": "userId", "type": "string", "required": true }
# ]
# }
# ]
# }
# Validate a schema file without publishing
onyx schema validate ./onyx.schema.json
# Diff local schema vs API
onyx schema diff ./onyx.schema.json
# Prints YAML with added/removed/changed tables and attribute differences between the API schema and your local file.When --tables is provided, the subset is printed to stdout instead of writing a
file. Otherwise, the CLI writes to ./onyx.schema.json by default.
In this repo's examples/ workspace, the following scripts wrap the same commands:
npm run schema:get # fetch remote schema into ./onyx.schema.json
npm run schema:validate # validate the local schema file
npm run schema:publish # validate then publish the local schemaThe CLI reuses the same configuration resolution as onyx.init() (env vars,
project config, and home profile files).
Programmatic diffing is also available:
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
const diff = await db.diffSchema(localSchema); // SchemaUpsertRequest
console.log(diff.changedTables);You can also emit to multiple paths in one run (comma-separated or by repeating --out):
onyx gen --ts --out ./src/onyx/types.ts,./apps/admin/src/onyx/types.tsUse in code:
import { onyx, eq, asc } from '@onyx.dev/onyx-database';
import { tables, Schema } from './src/onyx/types';
const db = onyx.init<Schema>();
const User = await db
.from(tables.User)
.where(eq('status', 'active'))
.orderBy(asc('createdAt'))
.limit(20)
.list();For a schema with User, UserProfile, Role, and Permission tables,
onyx gen emits plain interfaces keyed by IDs. Each interface includes an
index signature so resolver-attached fields or embedded objects remain
type-safe:
// AUTO-GENERATED BY onyx gen. DO NOT EDIT.
export interface User {
id?: string;
name: string;
[key: string]: any;
}
export interface Role {
id?: string;
title: "",
[key: string]: any;
}
export interface Permission {
id?: string;
description: string;
[key: string]: any;
}
export interface UserProfile {
id?: string;
age: number;
[key: string]: any;
createdDate: Date;
}
const user = await db
.from(tables.User)
.select('id', 'username')
.resolve('roles.permissions', 'profile')
.firstOrNull();
// user.roles -> Role[]
// user.roles[0]?.permissions -> Permission[]
// user.profile -> UserProfile | undefinedThe generator defaults to not emitting the JSON copy of your schema. Use
--emit-jsonif you want it.
Modeling users, roles, and permissions
User and Role form a many-to-many relationship through a UserRole join table. Role and Permission are connected the same way via RolePermission.
userRoles/rolePermissionsresolvers return join-table rows. Use these when cascading saves or deletes to add or remove associations.roles/permissionsresolvers traverse those joins and returnRoleorPermissionrecords for display.
Define these resolvers in your onyx.schema.json:
"resolvers": [
{
"name": "roles",
"resolver": "db.from(\"Role\")\n .where(\n inOp(\"id\", \n db.from(\"UserRole\")\n .where(eq(\"userId\", this.id))\n .list()\n .values('roleId')\n )\n)\n .list()"
},
{
"name": "profile",
"resolver": "db.from(\"UserProfile\")\n .where(eq(\"userId\", this.id))\n .firstOrNull()"
},
{
"name": "userRoles",
"resolver": "db.from(\"UserRole\")\n .where(eq(\"userId\", this.id))\n .list()"
}
]Save a user and attach roles in one operation:
await db.cascade('userRoles:UserRole(userId, id)').save('User', {
id: 'user_126',
email: '[email protected]',
userRoles: [
{ roleId: 'role_admin' },
{ roleId: 'role_editor' },
],
});Fetch a user with roles and each role's permissions:
const detailed = await db
.from('User')
.resolve('roles.permissions', 'profile')
.firstOrNull();
// detailed.roles -> Role[]
// detailed.roles[0]?.permissions -> Permission[]Remove a role and its permission links:
await db.cascade('rolePermissions').delete('Role', 'role_temp');Query helpers at a glance
Importable helpers for conditions and sort:
import {
eq, neq, within, notWithin, // preferred aliases for IN/NOT IN
inOp, notIn,
between,
gt, gte, lt, lte,
like, notLike, contains, notContains,
startsWith, notStartsWith, matches, notMatches, search,
approximateSearch, hnswCandidates, approximateCandidates,
isNull, notNull,
asc, desc
} from '@onyx.dev/onyx-database';- Prefer
within/notWithinfor inclusion checks (supports arrays, comma-separated strings, or inner queries). inOp/notInremain available for backward compatibility and are exact aliases.search(text, minScore?)keeps the native vector-managedMATCHESpredicate on__full_text__and always serializesminScore(null when omitted).search(text, { mode, ... })builds the high-levelSEARCHpredicate for lexical, semantic, or hybrid retrieval. An empty options object defaults to hybrid mode andmatch: "any".approximateSearch,hnswCandidates, andapproximateCandidatesbuild physically bounded, read-only candidate criteria. Pass the ordinary-index helper towhere(approximateCandidates(...)), just likewhere(eq(...)).approximateSearchandhnswCandidatesmust be the sole root criterion. OneapproximateCandidatescriterion may be combined with non-negatedANDfilters; query builders rejectOR, negation, duplicate candidate admission, and update/delete execution before transport.
Aggregate helpers
import {
avg, sum, count, min, max,
std, variance, median,
upper, lower,
substring, replace, percentile,
format
} from '@onyx.dev/onyx-database';
const rows = await db
.select(format('createdAt', 'yyyy-MM-dd'))
.from('User')
.list();format(field, formatter)uses Java-style format strings for dates and numbers.- Example:
examples/query/format.ts
Inner queries (IN/NOT IN with sub-selects)
You can pass another query builder to within or notWithin to create nested filters. The SDK serializes the inner query (including its table) before sending the request.
import { onyx, within, notWithin, eq, tables, Schema } from '@onyx.dev/onyx-database';
const db = onyx.init<Schema>();
// Users that HAVE the admin role
const usersWithAdmin = await db
.from(tables.User)
.where(
within(
'id',
db.select('userId').from(tables.UserRole).where(eq('roleId', 'role-admin')),
),
)
.list();
// Roles that DO NOT include a specific permission
const rolesMissingPermission = await db
.from(tables.Role)
.where(
notWithin(
'id',
db.from(tables.RolePermission).where(eq('permissionId', 'perm-manage-users')),
),
)
.list();Native vector-managed and bounded candidate search
Use .search(text, options) on a query builder for high-level natural-language search, or call db.search(text, options) to target all tables (table = "ALL" in the request body). The options select lexical, semantic, or hybrid retrieval; {} selects hybrid with all canonical defaults.
The existing one-argument .search(text) and numeric .search(text, minScore) forms remain the legacy MATCHES API for compatibility. The legacy value always includes minScore and falls back to null when omitted.
import {
desc,
eq,
onyx,
search,
semanticVectorSignature,
tables,
type Schema,
} from '@onyx.dev/onyx-database';
const db = onyx.init<Schema>();
// Table-specific search with a minimum score
const recentUsers = await db
.from(tables.User)
.search('user bio text', 4.4)
.orderBy(desc('createdAt'))
.limit(5)
.list();
// Search across all tables (table: "ALL")
const acrossTables = await db.search('user bio text').list({ pageSize: 5 });
// Combine a search predicate with other filters
const activeMatch = await db
.from(tables.User)
.where(search('user bio text'))
.and(eq('isActive', true))
.firstOrNull();
// Natural-language lexical search. Extra question words do not prevent a match
// when any terms clear the requested score threshold.
const lexical = await db
.from('ActiveDocumentChunk')
.search('how do i calculate cost per horse', {
mode: 'lexical',
match: 'any',
minScore: 0.4,
maxCandidates: 500,
})
.list();
// Server-managed semantic search from ordinary text.
const semanticMatches = await db
.from('ActiveDocumentChunk')
.search('how do i calculate cost per horse', { mode: 'semantic' })
.list();
// Fuse lexical and semantic retrieval with the same clean API.
const hybrid = await db
.from('ActiveDocumentChunk')
.search('how do i calculate cost per horse', {}) // mode defaults to hybrid
.and(eq('isActive', true))
.list();
// Semantic or hybrid MATCHES search. The helper validates the routing signature
// and preserves each 64-bit identifier/fingerprint word losslessly on the wire.
const semantic = semanticVectorSignature({
calibrationId: 73n,
bucketId: 6,
cells: [1, 2],
cellCounts: [4, 4],
fingerprint: ['0xfedcba9876543210'],
boundaryConfidence: 0.75,
});
const hybridMatches = await db
.from('ActiveDocumentChunk')
.search({
text: 'customer success',
semantic,
minScore: 0.42,
nearbyBucketRadius: 2,
maxCandidates: 321,
requireAllTerms: false,
})
.list();
// Bounded lexical admission. For a partitioned table, select one partition.
const lexicalCandidates = await db
.from('ActiveDocumentChunk')
.approximateSearch('customer success', { maxCandidates: 128 })
.inPartition('revision-7')
.limit(20)
.list();
// Native HNSW admission. The calibration id is text to preserve signed int64 values.
const semanticCandidates = await db
.from('ChunkAttentionHash')
.hnswCandidates({
calibrationId: '73',
vector: promptEmbedding,
maxCandidates: 256,
efSearch: 1024,
})
.inPartition('revision-7')
.limit(20)
.list();
// Bounded admission from one ordinary secondary index.
const hashCandidates = await db
.from('ChunkAttentionHash')
.where(approximateCandidates('bucketId', [1201, 1202, 1203], 1024))
.and(eq('active', true))
.and(eq('corpusId', 'corpus-a'))
.inPartition('revision-7')
.list();Candidate helpers enforce the public server bounds: at most 5,000 admitted rows,
at most 20,000 HNSW distance evaluations, at most 16,384 vector dimensions, and
at most 5,000 ordinary-index route values. Candidate results are approximate;
rerank them when exact ordering matters. One CANDIDATES condition may be combined
with ordinary predicates through AND in either call order. The server admits the
bounded route once and evaluates all predicates only over that set; OR trees remain
invalid because they could expand beyond the admission bound.
Omitted vector-search options use the server contract defaults: nearbyBucketRadius = 1,
maxCandidates = 1000, and requireAllTerms = true. HNSW defaults to
maxCandidates = 1000, efSearch = max(1000, maxCandidates), minScore = null,
and formatVersion = 1. SEARCH_CANDIDATES is deliberately text-only; semantic and
hybrid searches use MATCHES through .search({...}).
The high-level options overload emits a dedicated SEARCH criterion. Its canonical
defaults are mode = "hybrid", match = "any", minScore = null, and
maxCandidates = 1000; a non-null high-level score must be between 0 and 1 inclusive.
Hybrid search requires at least two candidates so both retrieval channels can run;
lexical-only and semantic-only search allow one.
SEARCH is read-only and can be combined with ordinary structured filters. It may appear
only once and cannot be combined with another __full_text__ predicate. The lower-level
SEARCH_CANDIDATES and HNSW_CANDIDATES operators remain sole-root operations. High-level
and candidate-admission queries cannot be used as live query streams. Existing one-argument
.search(text), .search(text, minScore), and .search(VectorSearchQueryInput) calls retain
their original MATCHES wire format.
A table-scoped high-level search spans that table's current partitions by default under
one global candidate budget; call .inPartition(...) to constrain it. Low-level
candidate APIs still require one concrete partition. Database-wide db.search(text,
options) searches eligible unpartitioned tables only, never inherits the client's
default partition, and returns typed FullTextSearchResult envelopes with id,
entityType, entity, and a normalized nullable score.
Semantic and hybrid modes require the database server to have a search embedding
provider configured. Stored searchable text and query text must be embedded with the
same model, calibration, and vector space. Records written before that integration was
enabled need to be resaved or backfilled so their HNSW vectors exist; marking a table
SEARCHABLE alone does not retroactively embed existing records.
Examples
- Table search (minScore null):
examples/query/vector-table-search.ts - Table search (minScore 4.4):
examples/query/vector-table-search-min-score.ts - ALL tables search (minScore null):
examples/query/vector-search-all-tables.ts - ALL tables search (minScore 4.4):
examples/query/vector-search-all-tables-min-score.ts
Usage examples with User, Role, Permission
The examples assume your schema has tables named
User,Role, andPermission.
If you generated types, replace string literals withtables.User,tables.Role, etc., and type your results withSchema.
1) List (query & paging)
import { onyx, eq, contains, asc } from '@onyx.dev/onyx-database';
const db = onyx.init();
// Fetch first 25 active User whose email contains "@example.com"
const firstPage = await db
.from('User')
.where(eq('status', 'active'))
.and(contains('email', '@example.com'))
.orderBy(asc('createdAt'))
.limit(25)
.page(); // or .list() for array-like results with nextPage
// Iterate to fetch all pages:
const allActive = await db
.from('User')
.where(eq('status', 'active'))
.list();
// Collect IDs across all pages
const ids = await db.from('User').list().values('id');
// Get the first user across pages
const firstUser = await db.from('User').list().firstOrNull();
// Call any QueryResults helper before awaiting
const size = await db.from('User').list().size();1b) First or null
const maybeUser = await db
.from('User')
.where(eq('email', '[email protected]')) // avoid searching by indentifier with firstOrNull, it will throw not found error
.firstOrNull(); // or .one()1c) Terminal formatters
const table = await db.from('User').select('id', 'email').table();
const tree = await db.from('User').select('id', 'email').tree({ rootLabel: 'users' });
const csv = await db.from('User').select('id', 'email').csv({ headers: false });
const json = await db.from('User').select('id', 'email').json();
console.log(table);
console.log(tree);
console.log(csv);
console.log(json);table() renders readable box-drawing output, tree() expands nested objects hierarchically, csv() flattens nested objects with dot notation by default, and json() preserves the original nested structure.
2) Save (create/update)
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
// Upsert a single user
await db.save('User', {
id: 'user_123',
email: '[email protected]',
status: 'active',
});
// Batch upsert User
await db.save('User', [
{ id: 'user_124', email: '[email protected]', status: 'active' },
{ id: 'user_125', email: '[email protected]', status: 'invited' },
]);
// Save many users in batches of 500
await db.batchSave('User', largeUserArray, 500);
// Save with cascade relationships (example)
await db.cascade('userRoles:UserRole(userId, id)').save('User', {
id: 'user_126',
email: '[email protected]',
userRoles: [
{ roleId: 'role_admin' },
{ roleId: 'role_editor' },
],
});
// Cascade relationship syntax:
// field:Type(target, source)
// field – property or path relative to the entity being saved
// Type – related table name
// target – foreign key on the related table
// source – field on the top level entity used as the key
// Using the CascadeRelationshipBuilder
const permission = { id: 'perm_edit_content', description: 'Edit content' };
const permissionsCascade = db
.cascadeBuilder()
.graph('permissions')
.graphType('Permission')
.targetField('roleId')
.sourceField('id');
await db.cascade(permissionsCascade).save('Role', {
id: 'role_editor',
name: 'Editor',
permissions: [permission],
});3) Delete (by primary key)
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
// Simple delete returns true when the request succeeds
const deleted = await db.delete('User', 'user_125');
// Delete cascading relationships (example)
await db.delete('Role', 'role_temp', { relationships: ['rolePermissions'] });
// this will delete all of the related permissions that come back from the rolePermissions resolver
// builder pattern equivalent
await db.cascade('rolePermissions').delete('Role', 'role_temp');4) Delete using query
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
const delCount = await db
.from(tables.User)
.where(eq('status', 'inactive'))
.delete();
//this will delete all inactive users in the system
5) Schema API
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
// Fetch current schema (optionally filter by tables)
const schema = await db.getSchema({ tables: ['User', 'Profile'] });
// Review history
const history = await db.getSchemaHistory();
// Validate changes without applying
await db.validateSchema({
revisionDescription: 'Add profile triggers',
entities: [
{
name: 'Profile',
identifier: { name: 'id', generator: 'UUID' },
attributes: [
{ name: 'id', type: 'String', isNullable: false },
{ name: 'userId', type: 'String', isNullable: false },
],
},
],
});
// Update and optionally publish
await db.updateSchema(
{
revisionDescription: 'Publish profile changes',
entities: [
{
name: 'Profile',
type: 'SEARCHABLE',
// LEXICAL, SEMANTIC, or BOTH (the backward-compatible default)
searchSupport: 'BOTH',
identifier: { name: 'id', generator: 'UUID' },
attributes: [
{ name: 'id', type: 'String', isNullable: false },
{ name: 'userId', type: 'String', isNullable: false },
],
},
],
},
{ publish: true },
);searchSupport controls which indexes a SEARCHABLE entity maintains and therefore
which high-level search modes it accepts. Use LEXICAL for term matching,
SEMANTIC for embedding/HNSW retrieval, or BOTH to allow lexical, semantic, and
hybrid queries. The field is ignored for DEFAULT entities. Changing it rebuilds
the entity's search indexes; existing schema JSON without the field behaves as BOTH.
6) Secrets API
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();
// List secret metadata
const list = await db.listSecrets();
// Read a decrypted secret value
const secret = await db.getSecret('api-key');
// Create or update a secret
await db.putSecret('api-key', {
value: 'super-secret',
purpose: 'Access to external API',
});
// Delete a secret
await db.deleteSecret('api-key');7) Documents API (binary assets)
import { onyx, type OnyxDocument } from '@onyx.dev/onyx-database';
const db = onyx.init();
// Save / upload a document (Base64 content)
const logoPng = Buffer.from('89504E47...', 'hex').toString('base64');
const doc: OnyxDocument = {
documentId: 'logo.png',
path: '/brand/logo.png',
mimeType: 'image/png',
content: logoPng,
};
await db.saveDocument(doc);
// Get a document (optionally with resizing hints if supported)
const image = await db.getDocument('logo.png', { width: 128, height: 128 });
// Delete a document
await db.deleteDocument('logo.png');8) Streaming (live changes)
import { onyx, eq } from '@onyx.dev/onyx-database';
const db = onyx.init();
const stream = db
.from('User')
.where(eq('status', 'active'))
.onItemAdded((u) => console.log('USER ADDED', u))
.onItemUpdated((u) => console.log('USER UPDATED', u))
.onItemDeleted((u) => console.log('USER DELETED', u))
.onItem((entity, action) => console.log('STREAM EVENT', action, entity));
// Start the stream and keep the connection alive for new events:
const handle = await stream.stream(true, true);
// Later, cancel:
setTimeout(() => handle.cancel(), 60_000);Debugging: set
ONYX_STREAM_DEBUG=1to log stream connection details.
Error handling
- OnyxConfigError – thrown by
init()if required connection parameters are missing. - HttpError – thrown for non-2xx API responses, with status and message from the server.
Use standard try/catch or .catch() patterns:
try {
const db = onyx.init();
// ...perform queries...
} catch (err) {
console.error('Onyx error:', err);
// Handle configuration or HTTP errors here.
}HTTP retries
- GET requests retry automatically with Fibonacci backoff (300ms base) up to 3 times by default; mutations are never retried.
- Disable or tune via
retryononyx.init:
const db = onyx.init({
retry: {
enabled: true, // default
maxRetries: 2, // default 3
initialDelayMs: 500, // default 300
},
});Onyx CLI
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| Command | Flags | Defaults / notes |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx gen | --ts/--typescript | Default: --source file; --schema ./onyx.schema.json; |
| | --source auto|api|file | --out ./onyx/types.ts (file or dir; repeatable); |
| | --schema <path> | schema type name: OnyxSchema; timestamps default: date. |
| | --out <dir|file> | Use --overwrite to force output; quiet=false. |
| | --name <T> | |
| | --timestamps string|date|number | |
| | --overwrite / --no-overwrite | |
| | -q / --quiet | |
| | -h / --help | |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx schema get | [file] (positional) | Default file: ./onyx.schema.json; writes file unless |
| | --tables a,b | --tables or --print (then prints to stdout). |
| | --print | |
| | -h / --help | |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx schema publish | [file] (positional) | Default file: ./onyx.schema.json; validates before publishing; |
| | -h / --help | uses onyx.init credential resolver. |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx schema validate | [file] (positional) | Default file: ./onyx.schema.json; exits non-zero on errors. |
| | -h / --help | |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx schema diff | [file] (positional) | Default file: ./onyx.schema.json; prints YAML diff vs API. |
| | -h / --help | |
+----------------------+-----------------------------------------------+--------------------------------------------------------------+
| onyx schema info | -h / --help | Shows resolved config sources, config path, connection check.|
+----------------------+-----------------------------------------------+--------------------------------------------------------------+Runtime & bundlers
- ESM:
dist/index.js - CJS:
dist/index.cjs - Types:
dist/index.d.ts
Works in Node 18+ and modern bundlers (Vite, esbuild, Webpack). For TypeScript, prefer:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true
}
}Release workflow
This repository uses Changesets for versioning and publishing.
- Run
npm run changesetto create a changeset entry. - Push to
mainand the Release workflow opens a version PR. - Push the version commit and create a
v*tag to trigger the publish job. - The publish job uses npm trusted publishing from GitHub Actions (OIDC), so npm must be configured with a trusted publisher for
OnyxDevTools/onyx-databaseand theReleaseworkflow file.
Related links
- Onyx website: https://onyx.dev/
- Cloud console: https://cloud.onyx.dev
- Docs hub: https://onyx.dev/documentation/
- Cloud API docs: https://onyx.dev/documentation/api-documentation/
Security
See SECURITY.md for our security policy and vulnerability reporting process.
License
MIT © Onyx Dev Tools. See LICENSE.
Keywords: Onyx Database TypeScript SDK, Onyx Cloud Database, Onyx NoSQL Graph Database client, TypeScript query builder, tables enum, schema code generation, zero-dependency database client, ESM CJS, Node.js database SDK, User Role Permission example
