Introducing Version 0.7.4

Type-safe queries for SurrealDB.

A fluent-api query builder with native graph support. Generates SurrealQL under the hood via db.query() and infers result types directly from your schema — no manual casting.

Get Started
$ npm install @mateosuarezdev/surrealqb

Database setup

Create a Surreal instance, connect it once at server startup, and pass it to SurrealQB. Everything — queries, mutations, batch, transactions — goes through the instance.

import { Surreal } from "surrealdb";
import { SurrealQB } from "@mateosuarezdev/surrealqb";

const surreal = new Surreal();

export async function connectDb() {
  try {
    await surreal.connect(process.env.SURREAL_URL!, {
      namespace: "my_namespace",
      database: "my_database",
      authentication: {
        username: process.env.SURREAL_USER!,
        password: process.env.SURREAL_PASSWORD!,
      },
    });
  } catch (error) {
    console.error("Error connecting to SurrealDB", error);
  }
}

export const db = new SurrealQB(surreal);

Call await connectDb() once when your server starts. The authentication block handles sign-in automatically — no separate surreal.signin() call needed.

Define a table

The TypeScript type is inferred automatically — hover over any collect() result in your editor and you'll see the expanded shape.

import {
  createTable,
  string,
  literal,
  number,
  decimal,
  boolean,
  datetime,
  record,
  object,
  array,
} from "@mateosuarezdev/surrealqb";

const usersTable = createTable("users", {
  id: record("users"),
  email: string().nullable(),
  age: number(),
  commission_rate: decimal(),
  active: boolean(),
  role: literal("admin", "user", "staff"),
  tags: array(string()),
  profile: object({
    name: string().nullable(),
    bio: string().optional(), // may not exist — SurrealDB option<string>
  }),
  created_at: datetime(),
});

Column types

Factory TypeScript type SurrealDB type
string() string string
literal("a", "b") "a" | "b" string
number() number number
decimal() Decimal decimal
boolean() boolean bool
datetime() string datetime
record("table") RecordId record
object({ ... }) inferred shape object
array(col) T[] array

literal() stores the allowed values in the descriptor — it's the hook for future DEFINE FIELD role TYPE "admin" | "staff" | "user" schema generation.

Datetime input and output

SurrealDB's SDK returns datetime() fields as ISO strings in query results. Mutation input is deliberately different: pass the SDK's DateTime value so SurrealDB receives a real datetime, not a plain string.

import { DateTime } from "surrealdb";

await db.from(usersTable).create({
  email: "new@example.com",
  created_at: new DateTime(),
});

await db
  .from(usersTable)
  .where(gte(usersTable.created_at, new DateTime("2026-01-01T00:00:00Z")))
  .update()
  .merge({ updated_at: new DateTime() })
  .collect();

Created and queried rows still expose datetime fields as string. For an optional() datetime, use undefined to write SurrealDB NONE; use null only when the field is explicitly nullable().

Decimal input and output

SurrealDB returns decimal() fields as SDK Decimal values, and mutation input deliberately uses that same precise type. Construct it from a string; do not use JavaScript floating-point values for financial or contractual arithmetic.

import { Decimal } from "surrealdb";

const rate = new Decimal("0.15");
const commission = new Decimal("9.00").mul(rate);

await db.from(partnersTable).create({ commission_rate: rate });
await db
  .from(partnersTable)
  .where(gte(partnersTable.commission_rate, new Decimal("0.10")))
  .collect();

Keep Decimal values through server-side calculations. At a JSON API boundary, ToClient<T> and toClient() infer Decimal as string, matching its precise JSON serialization.

Modifiers

Modifier Type change Stored as
.nullable() T | null null — field exists with a null value
.optional() T | undefined NONE — field may not exist (option<T> in SurrealDB)

They can be combined: string().nullable().optional()string | null | undefined. When writing, passing null stores null; passing undefined omits the field entirely, which SurrealDB stores as NONE.

SELECT

db.from(table) is the entry point. .collect() executes and returns a typed array.

import { eq, and, gt } from "@mateosuarezdev/surrealqb";

// SELECT * FROM users WHERE email = $p0
const users = await db
  .from(usersTable)
  .where(eq(usersTable.email, "john@example.com"))
  .collect();

// Chaining conditions, ordering, pagination
const admins = await db
  .from(usersTable)
  .where(and(eq(usersTable.role, "admin"), gt(usersTable.age, 18)))
  .orderBy(usersTable.created_at, "DESC")
  .limit(10)
  .offset(20)
  .collect();

Select by record ID

Uses SurrealDB's native record ID selection — no WHERE clause, no param binding.

// SELECT * FROM users:abc123
const user = await db.from(usersTable).byId("abc123").first();

Available chain methods

Method Description
.byId(id) Select a single record by ID — SELECT * FROM table:id
.where(condition) Filter rows
.orderBy(col, "ASC" | "DESC") Sort results
.limit(n) Limit number of results
.offset(n) Skip N rows (START AT in SurrealQL)
.fetch(...cols) Expand record links (FETCH field)
.first() Returns first result or undefined
.collect() Execute and return T[]
.toSQL() Returns { sql, params } without executing — useful for debugging

Projections

Use .select() after .where() to define custom projections. The returned type merges the base record with any aliased fields you add.

import { all, pick, as, expand, subquery, parent, rid } from "@mateosuarezdev/surrealqb";

all() — keep all base fields

db.from(postsTable).select(all());
// SELECT * FROM posts

pick(...cols) — select specific fields

Replaces all() when you only want a subset of the base fields. The result type is narrowed exactly to the picked keys.

db.from(postsTable).select(pick(postsTable.id, postsTable.title));
// SELECT id, title FROM posts
// result type: { id: RecordId; title: string }

Can be combined with as() projections:

db.from(postsTable).select(
  pick(postsTable.id, postsTable.title),
  as(expand(postsTable.author_id, usersTable), "author"),
);
// SELECT id, title, author_id.* AS author FROM posts
// result type: { id: RecordId; title: string; author: UserRecord }

as(expr, alias) — alias any expression

db.from(postsTable).select(
  all(),
  as(expand(postsTable.author_id, usersTable), "author"),
);
// SELECT *, author_id.* AS author FROM posts

expand(col, targetTable?, fields?) — inline record expansion

Without fields, expands all subfields (field.*):

as(expand(postsTable.author_id, usersTable), "author");
// → author_id.* AS author
// result.author typed as the full users record shape

With fields, uses SurrealDB's destructuring syntax (field.{key1, key2}) and narrows the result type:

as(
  expand(postsTable.author_id, usersTable, { email: true, role: true }),
  "author",
);
// → author_id.{email, role} AS author
// result.author typed as { email: string | null; role: "admin" | "user" | "staff" }

Omitting targetTable gives unknown — pass it when you want a typed result.

subquery(builder).index(n) — correlated subquery

db.from(woSessionsExercises)
  .where(eq(woSessionsExercises.woSession, rid("wo_sessions", "abc")))
  .select(
    all(),
    as(expand(woSessionsExercises.exercise), "exercise_details"),
    as(
      subquery(
        db
          .from(gymContentTable)
          .where(
            and(
              eq(
                gymContentTable.exercise,
                parent(woSessionsExercises.exercise),
              ),
              eq(gymContentTable.gym, rid("gyms", "rmgym")),
            ),
          ),
      ).index(0),
      "gym_content",
    ),
  );

Mutations

All mutations chain off db.from(table). Call .where() first to scope them, then the mutation, then .collect() to execute and get the affected records back.

Update

update() returns a pending builder. Chain .merge() or .set() to pick the mode.

.merge(values) — deep-merge a partial object

For standard CRUD. Pass a partial object from a client payload; unmentioned fields are untouched and nested objects deep-merge automatically.

// UPDATE users MERGE $p0 WHERE email = $p1
const updated = await db
  .from(usersTable)
  .where(eq(usersTable.email, "john@example.com"))
  .update()
  .merge({ active: false })
  .collect();

.set(values) — field-by-field assignments

For domain operations. Explicit field-by-field mutations with operator support — use when the action has a name: addToCart, incrementCount, tagAsVerified.

import { append, subtract } from "@mateosuarezdev/surrealqb";

// UPDATE users SET age -= $p0, tags += $p1 WHERE email = $p2
await db
  .from(usersTable)
  .where(eq(usersTable.email, "john@example.com"))
  .update()
  .set({ age: subtract(1), tags: append(["verified"]) })
  .collect();

Plain values emit field = $p. Use append(value) for += and subtract(value) for -= — useful for array mutations and numeric counters.

Delete

// DELETE users WHERE active = $p0 RETURN BEFORE
const deleted = await db
  .from(usersTable)
  .where(eq(usersTable.active, false))
  .delete()
  .collect();
// returns records as they were before deletion

RETURN clause

Both update() and delete() support .return() to control what SurrealDB sends back.
delete() defaults to RETURN BEFORE. update() defaults to SurrealDB's own default (AFTER).

// Don't send records back — fire and forget
await db.from(usersTable).where(...).delete().return("NONE").collect();

// Get records as they were before the update
await db.from(usersTable).where(...).update().merge({ active: false }).return("BEFORE").collect();

// Get a JSON Patch diff of what changed
await db.from(usersTable).where(...).update().merge({ active: false }).return("DIFF").collect();
Clause Returns
BEFORE Records as they were before the operation
AFTER Records after the operation (update default)
DIFF JSON Patch diff of the changes
NONE Nothing — skips fetching data back

Create

// CREATE users CONTENT $p0
const [created] = await db
  .from(usersTable)
  .create({ email: "new@example.com", age: 25, role: "user", active: true })
  .collect();

// CREATE users:myid CONTENT $p0 — chain byId() to pin the record ID
const [withId] = await db
  .from(usersTable)
  .byId("myid")
  .create({ email: "new@example.com", age: 25, role: "user", active: true })
  .collect();

Insert (bulk)

// INSERT INTO users $p0
const created = await db
  .from(usersTable)
  .insert([
    { email: "a@example.com", role: "user" },
    { email: "b@example.com", role: "staff" },
  ])
  .collect();

RELATE

Creates a graph edge between two records.

import { rid } from "@mateosuarezdev/surrealqb";

// RELATE users:john->likes->posts:surreal
await db
  .relate(rid("users", "john"), likesTable, rid("posts", "surreal"))
  .collect();

// With edge payload
// RELATE users:john->likes->posts:surreal CONTENT $p0
await db
  .relate(rid("users", "john"), likesTable, rid("posts", "surreal"))
  .content({ created_at: new Date().toISOString() })
  .collect();

collect() returns the created edge record typed as InferRecord<typeof likesTable._schema>[]. Works inside db.batch() like any other builder.

Graph relations

In SurrealDB, graph relations are just tables with in and out as record ID fields. Define them with createTable like any other table.

const likesTable = createTable("likes", {
  id: record("likes"),
  in: record("users"),
  out: record("posts"),
  created_at: datetime(),
});

Querying the edge table directly

// All likes by a user, with the linked post expanded
const likes = await db
  .from(likesTable)
  .where(eq(likesTable.in, rid("users", "john")))
  .select(all(), as(expand(likesTable.out, postsTable), "post"))
  .collect();

// likes[0].post → typed as PostRecord

Graph traversal syntax

right(), left(), and both() generate SurrealDB's native traversal paths and can be used directly inside .select() with as().

import { right, left, both } from "@mateosuarezdev/surrealqb";
Function SurrealQL Direction
right(relation, target) ->relation->target.* Outgoing
left(relation, target) <-relation<-target.* Incoming
both(relation, target) <->relation<->target.* Both
// All posts liked by a user — ->likes->posts.*
const user = await db
  .from(usersTable)
  .byId("john")
  .select(all(), as(right(likesTable, postsTable), "liked_posts"))
  .collect();
// user[0].liked_posts → PostRecord[]

// All users who liked a post — <-likes<-users.*
const post = await db
  .from(postsTable)
  .byId("surreal-is-great")
  .select(all(), as(left(likesTable, usersTable), "liked_by"))
  .collect();
// post[0].liked_by → UserRecord[]

Multi-hop traversal

Traversals are chainable — each step appends another hop to the path.

// ->likes->posts<-likes<-users.* — users who liked the same posts as john
const result = await db
  .from(usersTable)
  .byId("john")
  .select(
    all(),
    as(right(likesTable, postsTable).left(likesTable, usersTable), "co_likers"),
  )
  .collect();
// result[0].co_likers → UserRecord[]

The type at each step is inferred from the target table you pass, so TypeScript always knows the shape of the final result.

Nested field access

Columns defined with object() automatically expose their sub-fields as dot-notation properties. No helpers needed — just access them directly.

const usersTable = createTable("users", {
  profile: object({
    name: string().nullable(),
    bio: string().nullable(),
  }),
  // ...
});

// usersTable.profile       → BoundCol<"profile", { name: string | null, bio: string | null }>
// usersTable.profile.name  → BoundCol<"profile.name", string | null>
// usersTable.profile.bio   → BoundCol<"profile.bio", string | null>

Use them anywhere a column reference is accepted:

// WHERE profile.name = $p0
db.from(usersTable).where(eq(usersTable.profile.name, "John")).collect();

// ORDER BY profile.name ASC
db.from(usersTable).orderBy(usersTable.profile.name).collect();

Nesting is recursive — objects inside objects expand depth-first. RecordId, arrays, and primitives are left as plain BoundCols and don't expand further.

Typed record traversal

Pass a table to record() instead of a string to enable dot-notation traversal across linked records in WHERE conditions — the same way SurrealDB natively follows record links.

const clientsTable = createTable("clients", {
  id: record("clients"),
  token: string(),
  // ...
});

const projectsTable = createTable("projects", {
  id: record("projects"),
  client: record(clientsTable), // typed link
  type: literal("landing", "store"),
  // ...
});

const reviewsTable = createTable("reviews", {
  id: record("reviews"),
  project: record(projectsTable), // typed link
  // ...
});

Sub-fields of the linked table are now accessible with dot notation anywhere a column reference is accepted:

// Single hop — project.type
db.from(reviewsTable).where(eq(reviewsTable.project.type, "landing")).collect();
// WHERE project.type = $p0

// Two hops — project.client.token
db.from(reviewsTable)
  .where(eq(reviewsTable.project.client.token, "abc123"))
  .collect();
// WHERE project.client.token = $p0

// Combined with other conditions
db.from(reviewsTable)
  .where(
    and(
      eq(reviewsTable.project.client.token, "abc123"),
      eq(reviewsTable.project.type, "landing"),
    ),
  )
  .collect();
// WHERE (project.client.token = $p0) AND (project.type = $p1)

SurrealDB evaluates the dot path automatically by following record links — no joins, no subqueries.

Traversal depth

Traversal is limited to 3 hops at both the type level and runtime to prevent infinite recursion on circular schemas. This covers virtually all real-world queries (a.b.c.d).

Backward compatibility

record("tableName") (string form) still works exactly as before — it produces a plain RecordId col with no sub-field access. The typed form is opt-in.

Untyped traversal — field<T>(path)

For fields defined with the string form record("table") — polymorphic links, union types, or any case where modelling the full schema isn't worth it — use field() as an escape hatch. It creates a bound col from a raw dot-path string, so param binding still works; you just lose compile-time path inference.

import { field } from "@mateosuarezdev/surrealqb";

// record("owners") is intentionally untyped — could be users or organisations
db.from(ordersTable)
  .where(eq(field<string>("owner.email"), "john@example.com"))
  .collect();
// WHERE owner.email = $p0  — param-bound, no type inference

// works in orderBy, pick, and any other place that accepts a BoundCol
db.from(ordersTable).orderBy(field("owner.created_at")).collect();

field() defaults to unknown — pass a type argument when you need the value type checked: field<string>(...), field<number>(...).

Polymorphic records

record(tableA, tableB) — multiple tables — is supported at runtime but does not expose sub-fields for traversal yet. It resolves to a plain RecordId. Use field<T>() in the meantime for polymorphic traversal.

Batch queries

db.batch() runs multiple builders in a single db.query() call — one WebSocket round trip. Parameters are deduplicated across all statements automatically.

const [users, posts, sessions] = await db.batch(
  db.from(usersTable).where(eq(usersTable.active, true)),
  db.from(postsTable).where(gt(postsTable.views, 1000)),
  db.from(sessionsTable).where(isNull(sessionsTable.invalidated_at)),
);

// users    → User[]     (fully typed)
// posts    → Post[]     (fully typed)
// sessions → Session[]  (fully typed)

Works with any builder including relate():

const [edge] = await db.batch(
  db.relate(rid("users", "john"), likesTable, rid("posts", "abc")),
);
// edge → LikeRecord[]

Transactions

db.transaction(fn) — throws on failure

Wraps the callback in BEGIN TRANSACTION / COMMIT TRANSACTION. If the callback throws, the transaction is cancelled automatically and the error re-thrown.

await db.transaction(async (tx) => {
  const [user] = await tx
    .from(usersTable)
    .create({ email: "new@example.com", role: "user", active: true })
    .collect();

  await tx
    .relate(rid("users", user.id), likesTable, rid("posts", "abc"))
    .collect();
});

The tx argument is a SurrealQB scoped to the same connection — use it exactly like db.

db.safeTransaction(fn) — returns [error, result]

Same as transaction() but returns a tuple instead of throwing. Useful when you want to handle the error inline without a try/catch.

const [error, user] = await db.safeTransaction(async (tx) => {
  const [user] = await tx
    .from(usersTable)
    .create({ email: "new@example.com", role: "user", active: true })
    .collect();

  await tx
    .relate(rid("users", user.id), likesTable, rid("posts", "abc"))
    .collect();

  return user;
});

if (error) {
  // transaction was cancelled
}

Condition reference

Import from "@mateosuarezdev/surrealqb".

Function SurrealQL Notes
eq(col, value) field = $p
neq(col, value) field != $p
gt(col, value) field > $p
gte(col, value) field >= $p
lt(col, value) field < $p
lte(col, value) field <= $p
isNull(col) field = null field exists with null value
isNotNull(col) field != null
isNone(col) field = NONE field doesn't exist
inArray(col, values) field INSIDE $p field is one of the values
notInArray(col, values) field NOT INSIDE $p field is none of the values
arrayContains(col, value) field CONTAINS $p field (array) includes value
strContains(col, value) string::contains(field, $p) substring check
strStartsWith(col, value) string::starts_with(field, $p) prefix check
strEndsWith(col, value) string::ends_with(field, $p) suffix check
and(...conditions) (a) AND (b)
or(...conditions) (a) OR (b)
not(condition) NOT (a)

Raw values

Some SurrealDB values cannot be passed as query parameters and must be inlined.

rid(table, id) — record literal

import { rid } from "@mateosuarezdev/surrealqb";

db.from(sessionsTable).where(eq(sessionsTable.user_id, rid("users", "abc123")));
// WHERE user_id = users:abc123

parent(col) — parent record reference in subqueries

import { parent } from "@mateosuarezdev/surrealqb";

db.from(gymContentTable).where(
  eq(gymContentTable.exercise, parent(exercisesTable.id)),
);
// WHERE exercise = $parent.id

raw(sql) — arbitrary inline SurrealQL

import { raw } from "@mateosuarezdev/surrealqb";

db.from(usersTable).select(all(), as(raw("->likes->posts"), "liked_posts"));
// Use sparingly — no type safety or parameter binding

Type inference helpers

Two utility types let you extract TypeScript types from your schema and queries without boilerplate.

InferTable<T> — row type from a table definition

import { type InferTable } from "@mateosuarezdev/surrealqb";

type User = InferTable<typeof usersTable>;
// { id: RecordId; email: string | null; age: number; commission_rate: Decimal; active: boolean;
//   role: "admin" | "user" | "staff"; tags: string[];
//   profile: { name: string | null; bio: string | null }; created_at: string }

function processUser(user: InferTable<typeof usersTable>) {
  // user is fully typed
}

InferResult<T> — element type from a query builder

Works on any builder that has a collect()QueryBuilder, MergeUpdateBuilder, SetUpdateBuilder, DeleteBuilder, RelateBuilder, etc.

import { type InferResult } from "@mateosuarezdev/surrealqb";

const query = db.from(usersTable).where(eq(usersTable.active, true));

type User = InferResult<typeof query>;
// Same shape as above, inferred from the query's collect() return type

JSON API boundaries

InferTable<T> represents a server-side result: record fields are SDK RecordId objects, decimal fields are SDK Decimal values, and datetime fields are already ISO strings from the SDK. A JSON API cannot expose those SDK objects directly: JSON serialization emits RecordId and Decimal as strings. Use ToClient<T> when only the client-facing type is needed, or wrap a BRPC/API return value with toClient() so inference matches the JSON payload.

import { toClient, type InferTable, type ToClient } from "@mateosuarezdev/surrealqb";

type User = InferTable<typeof usersTable>;
type ClientUser = ToClient<User>;
// `id` and `commission_rate` are strings; `created_at` is already an ISO string.

async function getUserForApi() {
  const user = await db.from(usersTable).byId("mateo").first();
  return toClient(user);
}

toClient() is an inference-only identity helper. It does not clone or transform data: use it only at a boundary that serializes to JSON, such as a BRPC procedure response. JSON.stringify performs the actual RecordId and Decimal conversion; datetime query values are already ISO strings.

Roadmap

Conditions

  • eq, neq, gt, gte, lt, lte
  • and, or, not
  • isNull, isNotNull, isNone
  • inArray / notInArray
  • arrayContains
  • strContains, strStartsWith, strEndsWith

SELECT

  • .where(), .orderBy(), .limit(), .offset(), .fetch()
  • .byId(id)
  • .first()
  • .value(col)
  • .omit(...cols)
  • .groupBy(...cols) / .groupAll()
  • .split(...cols)
  • .timeout(duration)
  • .parallel()
  • .orderByNumeric() / .orderByCollate()
  • WITH INDEX / WITH NOINDEX hints

Mutations

  • .update().merge(values)
  • .update().set(values)
  • .delete()
  • .return(clause)
  • .create(values)
  • .byId().create(values)
  • .insert(values[])
  • .update().content(values)
  • .update().unset(...fields)
  • .update().patch(ops)
  • .insert().ignore()
  • .insert().onDuplicate(values)
  • UPSERT builder

Graph

  • db.relate(...)
  • right(), left(), both()
  • Chainable multi-hop traversal
  • Edge filtering
  • Fixed-depth / shortest path recursion
  • Depth range
  • Shortest path

Built-in functions

  • String functions
  • Array functions
  • Aggregate functions
  • Datetime functions

Infrastructure

  • db.batch(...builders)
  • db.transaction(fn) / db.safeTransaction(fn)
  • InferTable<T> / InferResult<T>
  • .toSQL() on all builders
  • LIVE SELECT
  • Schema DDL emitter

Debugging

Every builder exposes .toSQL() which returns the generated SQL and bound parameters without hitting the database:

const { sql, params } = db
  .from(usersTable)
  .where(and(eq(usersTable.role, "admin"), gt(usersTable.age, 18)))
  .toSQL();

console.log(sql);
// SELECT * FROM users WHERE (role = $p0) AND (age > $p1)
console.log(params);
// { p0: "admin", p1: 18 }

LLM usage

This package ships an llms.txt file — a compressed API cheatsheet optimised for LLM context windows.

To load it automatically when working on a project that consumes SurrealQB, add this line to that project's CLAUDE.md:

@path/to/surrealqb/llms.txt

Adjust the path to be relative to the project's CLAUDE.md. Claude Code will include the cheatsheet in every session for that project without any further setup.