DRAFTThis page is not published. Only visible in development mode.

TypeScript / JavaScript

@reifydb/client talks to a running ReifyDB server from Node.js or the browser: WebSocket for long-lived connections with live subscriptions, HTTP for plain request/response. Around it sit three companions - @reifydb/core with the value types and the Shape system that turns frames into typed objects, @reifydb/react with hooks for queries, commands, and subscriptions, and @reifydb/wasm with the whole engine compiled to WebAssembly. All packages ship together at the engine version: pair 0.8.x packages with a 0.8.x server.

Installation

bash
npm install @reifydb/client @reifydb/core

# React hooks (re-exports client and core):
npm install @reifydb/react

# In-browser engine, no server needed:
npm install @reifydb/wasm

@reifydb/client works unchanged in both runtimes: in the browser it uses the native WebSocket and fetch, in Node.js it dynamically loads the ws package for WebSocket connections. TypeScript is optional but is where the package earns its keep - the shape system gives every row a precise static type.

Connecting

Client.connect_ws(url, options?) opens one persistent WebSocket that carries everything: queries, commands, logins, and subscription pushes. Against the default server the WebSocket endpoint is ws://localhost:8091; Connect covers the port layout and the loopback-only admin listeners:

typescript
import { Client } from '@reifydb/client';
import { Shape } from '@reifydb/core';

const client = await Client.connect_ws('ws://localhost:8091');

const frames = await client.query(
  'MAP { answer: 42 }',
  {},
  [Shape.object({ answer: Shape.number() })],
);

console.log(frames[0][0].answer); // 42

await client.disconnect();

The options object tunes the connection; everything is optional:

typescript
const client = await Client.connect_ws('ws://localhost:8091', {
  timeout_ms: 10_000,         // per-request timeout, default 30_000
  token: session_token,       // authenticate the connection immediately
  format: 'frames',           // 'json' | 'frames' | 'rbcf', default 'frames'
  max_reconnect_attempts: 5,  // default 5
  reconnect_delay_ms: 1_000,  // base backoff delay, default 1_000
  signal: abort_controller.signal, // abort the connection attempt
});

If the socket drops, the client reconnects on its own: it retries up to max_reconnect_attempts times with exponential backoff starting at reconnect_delay_ms, re-sends the session token, and re-registers every active subscription. Requests that were in flight when the connection dropped fail with a CONNECTION_LOST error rather than hanging. disconnect() stops the reconnect loop and closes the socket for good.

Client.connect_http(url, options?) is the request/response equivalent - each statement is one HTTP POST against http://localhost:8090, which suits scripts, serverless functions, and anywhere a long-lived socket is awkward. It is synchronous (there is no connection to open) and carries the same query, command, admin, and login surface, but no subscriptions:

typescript
const http = Client.connect_http('http://localhost:8090');

const frames = await http.query(
  'MAP { answer: 42 }',
  {},
  [Shape.object({ answer: Shape.number() })],
);

The HTTP methods additionally accept a per-request { signal } as the fourth argument, so a single request can be cancelled with an AbortSignal without touching the client.

Authentication

A fresh connection runs as the anonymous identity; what that identity may do is decided by policies on the server. To act as a real user, log in with whichever method the user has (creating users and their credentials is an admin operation, shown on Connect):

typescript
const client = await Client.connect_ws('ws://localhost:8091');

// Password login: returns the session token plus the identity UUID.
const { token, identity } = await client.login_with_password('alice', 'alice-pass');

// Token login for users with token credentials:
await client.login_with_token('bob-secret-token');

// Revoke the session token server-side:
await client.logout();

// Resume an existing session on a new connection:
const resumed = await Client.connect_ws('ws://localhost:8091', { token });

Every statement after a successful login runs as that identity, and the client keeps the token so reconnections re-authenticate automatically. Logging in again replaces the current session - switching from alice to bob on the same connection is just a second login. The lower-level login(method, credentials) takes the method name and a plain object of credential fields directly. logout() without a token is a no-op, so it is always safe to call in cleanup paths.

Queries and commands

query(rql, params, shapes) reads, command(rql, params, shapes) writes. The third argument is an array with one Shape per result frame; rows arrive as typed objects matching it. A statement returns one frame per pipeline, so most calls pass a single shape:

typescript
await client.command(
  `INSERT app::users [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]`,
  null,
  [],
);

const users = Shape.object({ id: Shape.int4(), name: Shape.string() });

const frames = await client.query('FROM app::users', null, [users]);
for (const user of frames[0]) {
  console.log(user.id, user.name); // typed: number, string
}

Passing [] skips the typed transformation: with the default frames format each row is then a plain object of value wrappers from @reifydb/core (an Int4Value, a Utf8Value, ...) keyed by column name - useful when you do not know the columns up front. Each method also has a _with_meta twin (query_with_meta, command_with_meta, admin_with_meta) that returns the frames together with server-reported metadata - a request fingerprint and the server-side execution duration:

typescript
const { frames, meta } = await client.query_with_meta('FROM app::users', null, [users]);
console.log(meta?.fingerprint, meta?.duration);

Admin statements

The application endpoints (8090/8091) accept queries and commands only; admin statements such as CREATE TABLE are rejected there. The client's admin(rql, params, shapes) method works when the connection points at an admin listener, which the default server keeps on loopback (ws://127.0.0.1:9091 for WebSocket, http://127.0.0.1:9090 for HTTP - see Connect):

typescript
// Same client type, admin listener. Loopback only by default.
const admin = await Client.connect_ws('ws://127.0.0.1:9091', { token });

await admin.admin('CREATE NAMESPACE app', null, []);
await admin.admin('CREATE TABLE app::users { id: Int4, name: Utf8 }', null, []);

await admin.disconnect();

Admin listeners accept all three statement kinds, so a migration script can run its schema changes and seed data over one connection. The split exists so frontends can face the public ports while schema and identity changes stay reachable only from the machine itself.

Parameters

The second argument carries parameters. An array binds positional parameters $1, $2, ...; a plain object binds named parameters $name; null means none:

typescript
// Positional: $1, $2, ...
const sums = await client.query(
  'MAP { total: $1 + $2 }',
  [40, 2],
  [Shape.object({ total: Shape.number() })],
);

// Named: $name
const alices = await client.query(
  'FROM app::users FILTER name == $name',
  { name: 'Alice' },
  [users],
);

JavaScript values are encoded automatically as typed {type, value} pairs, never as spliced RQL text - use parameters for anything that originates outside your program, exactly as you would to prevent SQL injection. The encoding picks a type from the value: integers become the smallest fitting Int type, other numbers Float8, strings Utf8 (UUID-formatted strings become Uuid4/Uuid7), booleans Boolean, bigints an unsigned or signed 64/128-bit integer, Dates DateTime, Uint8Arrays Blob, and null/undefined None. When the exact engine type matters, pass a value wrapper from @reifydb/core instead:

typescript
import { Int4Value } from '@reifydb/core';

// Force Int4 instead of the auto-picked smallest integer type:
await client.query(
  'MAP { result: $1 }',
  [new Int4Value(42)],
  [Shape.object({ result: Shape.int4() })],
);

Shapes and typed results

A Shape describes the result you expect, and TypeScript infers the row type from it - Shape.object({ ... }) produces an object per row, and each field builder maps an engine type to a JavaScript one. The mapping follows what fits without loss: int1/int2/int4 and their unsigned versions become number, int8/int16/uint8/uint16 become bigint, float4/float8 become number, utf8 becomes string, boolean becomes boolean, date/datetime become Date, and decimal, time, duration, and the UUID types arrive as strings. Convenience aliases exist for the common cases: Shape.string(), Shape.number(), Shape.int(), Shape.bool().

typescript
const order = Shape.object({
  id: Shape.int8(),                 // bigint
  customer: Shape.string(),         // string
  total: Shape.float8(),            // number
  placed_at: Shape.datetime(),      // Date
  note: Shape.optional(Shape.string()), // string | undefined
});

const frames = await client.query('FROM app::orders', null, [order]);
const first = frames[0][0];
// first: { id: bigint; customer: string; total: number; placed_at: Date; note?: string }

Shape.optional(...) admits undefined for columns that can be missing, and Shape.array(...) types list-valued fields. Every primitive builder also has a Value twin (Shape.int4Value(), Shape.utf8Value(), ...) that keeps the wrapper object instead of unwrapping to a primitive - the right choice when you need lossless round-tripping, for example to pass a result back as a parameter with its exact engine type.

Shapes are client-side
The shape never travels to the server and does not validate anything server-side; it directs how the client decodes and types the frames it receives. If the shape names a type the column does not have, you get whatever the coercion produces, not an error.

Subscriptions

Subscriptions need the persistent connection, so they are WebSocket only. subscribe(rql, params, shape, callbacks, config?) registers a live subscription over an RQL body and returns its id; from then on the server pushes changes and the client dispatches them to your callbacks as typed rows, with the change kind already split out:

typescript
const user_shape = Shape.object({ id: Shape.int4(), name: Shape.string() });

const subscription_id = await client.subscribe(
  'from app::users | map { id, name }',
  null,
  user_shape,
  {
    on_insert: (rows) => console.log('insert', rows),
    on_update: (rows) => console.log('update', rows),
    on_remove: (rows) => console.log('remove', rows),
  },
);

// Later:
await client.unsubscribe(subscription_id);

All three callbacks are optional. The client derives the kind from the _op column the server attaches and strips that column before invoking you, so the rows look exactly like query results. The optional fifth argument controls delivery: hydration is on by default, meaning the current contents of the source are delivered as inserts first, so subscribing to a populated table observes every existing row before forward changes. hydration.max_rows caps that snapshot, and throttle sets a minimum interval in milliseconds between pushes, with changes buffered and delivered together when the interval elapses:

typescript
// Only changes committed after the subscription exists,
// pushed at most every 250ms:
await client.subscribe('from app::users', null, user_shape, callbacks, {
  hydration: { enabled: false },
  throttle: 250,
});

A dashboard that watches ten queries does not want ten interleaved change streams. batch_subscribe(members, batch_callbacks?) registers several subscriptions as one group whose changes arrive as coalesced per-tick envelopes; each member brings its own rql, shape, callbacks, and config, and the group-level callbacks report members closing:

typescript
const { batch_id, subscription_ids } = await client.batch_subscribe(
  [
    { rql: 'from app::users', shape: user_shape, callbacks: { on_insert: render_users } },
    { rql: 'from app::orders', callbacks: { on_insert: render_orders } },
  ],
  { on_member_closed: (id) => console.log('member closed:', id) },
);

// Tears the whole group down server-side:
await client.batch_unsubscribe(batch_id);

Under the hood the client wraps your query body in a CREATE SUBSCRIPTION WITH { ... } AS { body } statement, so the server-side semantics are exactly those of the subscriptions concept page. After an automatic reconnect, the client re-registers every active subscription and batch - your callbacks keep firing, though the new subscription re-hydrates according to its config.

React hooks

@reifydb/react wraps the client in hooks and re-exports both @reifydb/client and @reifydb/core, so it is the only import a React app needs. Wrap your tree in a ConnectionProvider; it opens a single shared connection and every hook underneath uses it:

typescript
import { ConnectionProvider } from '@reifydb/react';

export function App() {
  return (
    <ConnectionProvider config={{ url: 'ws://localhost:8091' }}>
      <UserList />
    </ConnectionProvider>
  );
}

useQueryOne(rql, params?, shape?, options?) runs a query when the component mounts and again whenever rql or params change. It returns is_executing, the result (with typed rows), and an error string:

typescript
import { useQueryOne, Shape } from '@reifydb/react';

const user_shape = Shape.object({ id: Shape.int4(), name: Shape.string() });

export function UserList() {
  const { is_executing, result, error } = useQueryOne('FROM app::users', null, user_shape);

  if (error) return <p>{error}</p>;
  if (is_executing || !result) return <p>Loading...</p>;

  return (
    <ul>
      {result.rows.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

useQueryMany is the multi-statement variant: it takes an array of shapes and returns results, one entry per frame. useCommandOne and useCommandMany mirror the two for writes - note that they, too, execute on mount and on every rql/params change, which suits declarative writes. For event-driven writes (a button click), use useCommandExecutor, which hands you a command function to call when you choose; useQueryExecutor is the same for reads.

useSubscription(rql, params?, shape?, options?) subscribes on mount and unsubscribes on unmount. It exposes the accumulated changes - an append-only list of { operation, rows, timestamp } events - plus is_subscribed, error, and the subscription_id. Derive your view state by reducing over the changes (the returned data array is currently always empty; do not rely on it):

typescript
import { useSubscription, Shape } from '@reifydb/react';

const user_shape = Shape.object({ id: Shape.int4(), name: Shape.string() });

export function LiveUsers() {
  const { changes, is_subscribed } = useSubscription(
    'from app::users | map { id, name }',
    null,
    user_shape,
  );

  // Fold the change log into current state. With hydration on (the
  // default), existing rows arrive as the first INSERT events.
  const users = new Map<number, string>();
  for (const change of changes) {
    for (const row of change.rows) {
      if (change.operation === 'REMOVE') users.delete(row.id);
      else users.set(row.id, row.name);
    }
  }

  return (
    <ul>
      {[...users].map(([id, name]) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  );
}

useConnection() exposes the shared connection itself: client, is_connected, is_connecting, connection_error, and connect/disconnect/reconnect functions - the escape hatch for anything the hooks do not cover, such as logging in.

In-browser WASM

@reifydb/wasm is not a client - it is the whole engine compiled to WebAssembly, running in the page with no server and no network. It is what powers the playground and every runnable snippet in these docs. The API is synchronous and shape-free: statements return decoded value objects directly.

typescript
import { create_wasm_db } from '@reifydb/wasm';

const db = await create_wasm_db();

db.admin('CREATE NAMESPACE app');
db.admin('CREATE TABLE app::users { id: Int4, name: Utf8 }');

db.command("INSERT app::users [{ id: 1, name: 'Alice' }]");
const results = db.query('FROM app::users');

db.free();

Because there is no network boundary, there are no ports and no admin split - admin, command, and query all run directly, with _with_params variants for parameterized statements and login_with_password/login_with_token/logout mirroring the identity flow when you want to exercise policies. State lives in browser memory and disappears when the page unloads; free() releases the engine explicitly. The right tool for demos, tests, tutorials, and evaluating RQL - not for data you intend to keep.

Wire formats

The format option picks the result encoding for the whole connection, on both the WebSocket and the HTTP client: 'frames' (the default) asks for columnar frames as JSON, 'json' for row-shaped JSON, and 'rbcf' for ReifyDB's binary columnar format - the compact choice for result-heavy workloads. Whatever you pick, the client decodes it before you see it: the encoding never changes what a statement means or returns. One nuance: subscription pushes are always columnar (frames or rbcf), because the change protocol rides on the columnar layout. The formats themselves are specified on Wire Formats. The package also exports JsonWsClient and JsonHttpClient (via Client.connect_json_ws and Client.connect_json_http) - shape-free variants whose query(rql, params?) returns plain JSON data, handy for quick scripts that just want to look at results.

Error handling

Server-side failures - bad RQL, policy denials, transaction conflicts - reject the promise with a ReifyError, the same diagnostic wrapper every surface uses: a stable code, the offending RQL fragment with line and column, an optional help text, and notes:

typescript
import { ReifyError } from '@reifydb/client';

try {
  await client.query('FROM app::does_not_exist', null, []);
} catch (err) {
  if (err instanceof ReifyError) {
    console.error(err.code);     // stable diagnostic code
    console.error(err.message);  // '[CODE] message'
    console.error(err.fragment); // { text, line, column } of the offending RQL
    console.error(err.help);     // help text when the server provides one
  }
}

Two failures are generated client-side: a request that outlives timeout_ms rejects with a plain Error('ReifyDB query timeout'), and a request sent while the socket is down rejects with a ReifyError whose code is CONNECTION_LOST. Note that the client has no built-in conflict retry: a transaction conflict comes back like any other error and retrying the command is up to you.

Where next
Connect has the server-side half: starting the server, ports, and creating users to log in as. The Wire Formats page specifies the encodings if you need to go under the client, and the Quickstart runs RQL in your browser with no setup at all.