# @onyx.dev/onyx-database

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![codecov](https://codecov.io/gh/OnyxDevTools/onyx-database/branch/main/graph/badge.svg)](https://codecov.io/gh/OnyxDevTools/onyx-database) [![npm version](https://img.shields.io/npm/v/%40onyx.dev/onyx-database.svg)](https://www.npmjs.com/package/@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](./docs)
- **Quality:** 100% unit test coverage enforced in CI

---

## Table of contents

- [Getting started](#getting-started-cloud--keys--connect)
- [Install](#install)
- [Initialize the client](#initialize-the-client)
- [MessagePack entity transport](#messagepack-entity-transport)
- [Onyx AI (chat & models)](#onyx-ai-chat--models)
- [Published model predictions](#published-model-predictions)
- [Generate schema types](#optional-generate-typescript-types-from-your-schema)
- [Query helpers](#query-helpers-at-a-glance)
- [Native vector-managed and bounded candidate search](#native-vector-managed-and-bounded-candidate-search)
- [Examples](#usage-examples-with-user-role-permission)
- [Error handling](#error-handling)
- [HTTP retries](#http-retries)
- [Onyx CLI](#onyx-cli)
- [Runtime & bundlers](#runtime--bundlers)
- [Release workflow](#release-workflow)
- [Related links](#related-links)
- [Security](#security)
- [License](#license)

---

## Getting started (Cloud ➜ keys ➜ connect)

1. **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**.
2. **Note your connection parameters**:  
   - `baseUrl` (e.g., `https://api.onyx.dev`)  
   - `databaseId`  
   - `apiKey`  
   - `apiSecret`  
3. **Install the SDK** in your project:

   ```bash
   npm i @onyx.dev/onyx-database
   ```

4. **Initialize the client** using env vars or explicit config.

> Supports Node.js **18+** and Cloudflare Workers.

---

## Install

```bash
npm i @onyx.dev/onyx-database
```

The 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):

```bash
curl -fsSL https://raw.githubusercontent.com/OnyxDevTools/onyx-cli/main/scripts/install.sh | bash

# macOS (Homebrew)
brew tap OnyxDevTools/onyx-cli
brew install onyx-cli
```

---

## Initialize 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 with `ttl`, clear via `onyx.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 |

```ts
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 default
```

### Option B) Explicit config

```ts
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'`:

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

```ts
import { onyx } from '@onyx.dev/onyx-database/edge';

const db = onyx.init(); // uses env vars in edge runtimes
```

File-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:**

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

```ts
// Untyped: flexible, no compile-time field checks
const db = onyx.init();
const user = await db.from('User').findById('abc'); // inferred as any/unknown
```

```ts
// 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 error
```

---

## Onyx 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`.

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

```ts
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 early
```

Tool calls mirror the ChatGPT TypeScript client:

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

```ts
const models = await db.ai.getModels();
const chatModel = await db.ai.getModel('onyx-chat');
```

### Script mutation approvals

```ts
const approval = await db.ai.requestScriptApproval({
  script: "db.save({ id: 'u1', email: 'a@b.com' })",
});
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.

```ts
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()`):

```bash
onyx gen --ts --source api --out ./src/onyx/types.ts --name OnyxSchema
```

With `--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:

```bash
onyx gen --ts --source file --schema ./onyx.schema.json --out ./src/onyx/types.ts --name OnyxSchema
```

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

```bash
# 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:

```bash
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 schema
```

The CLI reuses the same configuration resolution as `onyx.init()` (env vars,
project config, and home profile files).

Programmatic diffing is also available:

```ts
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`):

```bash
onyx gen --ts --out ./src/onyx/types.ts,./apps/admin/src/onyx/types.ts
```

Use in code:

```ts
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:

```ts
// 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 | undefined
```

> The generator defaults to not emitting the JSON copy of your schema. Use `--emit-json` if 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` / `rolePermissions` resolvers** return join-table rows. Use these when cascading saves or deletes to add or remove associations.
- **`roles` / `permissions` resolvers** traverse those joins and return `Role` or `Permission` records for display.

Define these resolvers in your `onyx.schema.json`:

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

```ts
await db.cascade('userRoles:UserRole(userId, id)').save('User', {
  id: 'user_126',
  email: 'dana@example.com',
  userRoles: [
    { roleId: 'role_admin' },
    { roleId: 'role_editor' },
  ],
});
```

Fetch a user with roles and each role's permissions:

```ts
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:

```ts
await db.cascade('rolePermissions').delete('Role', 'role_temp');
```

---

## Query helpers at a glance

Importable helpers for conditions and sort:

```ts
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`/`notWithin` for inclusion checks (supports arrays, comma-separated strings, or inner queries).  
- `inOp`/`notIn` remain available for backward compatibility and are exact aliases.
- `search(text, minScore?)` keeps the native vector-managed `MATCHES` predicate on `__full_text__` and always serializes `minScore` (null when omitted).
- `search(text, { mode, ... })` builds the high-level `SEARCH` predicate for lexical, semantic, or hybrid retrieval. An empty options object defaults to hybrid mode and `match: "any"`.
- `approximateSearch`, `hnswCandidates`, and `approximateCandidates` build physically bounded, read-only candidate criteria. Pass the ordinary-index helper to `where(approximateCandidates(...))`, just like `where(eq(...))`. `approximateSearch` and `hnswCandidates` must be the sole root criterion. One `approximateCandidates` criterion may be combined with non-negated `AND` filters; query builders reject `OR`, negation, duplicate candidate admission, and update/delete execution before transport.

### Aggregate helpers

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

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

```ts
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`, and `Permission`.  
> If you generated types, replace string literals with `tables.User`, `tables.Role`, etc., and type your results with `Schema`.

### 1) List (query & paging)

```ts
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

```ts
const maybeUser = await db
  .from('User')
  .where(eq('email', 'alice@example.com')) // avoid searching by indentifier with firstOrNull, it will throw not found error
  .firstOrNull(); // or .one()
```

### 1c) Terminal formatters

```ts
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)

```ts
import { onyx } from '@onyx.dev/onyx-database';
const db = onyx.init();

// Upsert a single user
await db.save('User', {
  id: 'user_123',
  email: 'alice@example.com',
  status: 'active',
});

// Batch upsert User
await db.save('User', [
  { id: 'user_124', email: 'bob@example.com', status: 'active' },
  { id: 'user_125', email: 'carol@example.com', 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: 'dana@example.com',
  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)

```ts
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

```ts
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

```ts
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

```ts
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)

```ts
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)

```ts
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=1` to 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:

```ts
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 `retry` on `onyx.init`:

```ts
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:

```json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "strict": true
  }
}
```
---

## Release workflow

This repository uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.

1. Run `npm run changeset` to create a changeset entry.
2. Push to `main` and the **Release** workflow opens a version PR.
3. Push the version commit and create a `v*` tag to trigger the publish job.
4. The publish job uses npm trusted publishing from GitHub Actions (OIDC), so npm must be configured with a trusted publisher for `OnyxDevTools/onyx-database` and the `Release` workflow 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](./SECURITY.md) for our security policy and vulnerability reporting process.

---

## License

MIT © Onyx Dev Tools. See [LICENSE](./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
