npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mateosuarezdev/surrealqb

v0.7.19

Published

A type-safe, query builder for SurrealDB with native graph support. Generates SurrealQL under the hood and infers result types directly from your schema — no manual casting.

Readme

SurrealDB Query Builder

Version 0.7.11 — see CHANGELOG.md for full history.

A type-safe, query builder for SurrealDB. Write expressive SurrealQL-like queries in TypeScript, with native graph support and result types inferred directly from your schema — no manual casting.

Table of contents


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

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(),
});

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


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() | DateTime | datetime | | record("table") | RecordId | record | | object({ ... }) | inferred shape | object | | array(col) | T[] | array |

Datetime precision

DateTime carries nanosecond precision, which a JavaScript Date cannot hold - .toDate() truncates to milliseconds. Prefer DateTime.now() over new DateTime(new Date()), which the Date caps at milliseconds before it reaches the constructor, and prefer its own .compare(), .equals() and .diff() over converting to compare.

import { DateTime } from "surrealdb";

await db.from(usersTable).create({
  email: "[email protected]",
  created_at: DateTime.now(),
});

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

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.

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

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.

Inside object({...}), an .optional() field is an optional property. Reading profile.bio is still string | undefined, while a sparse object may simply omit bio.


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, "[email protected]"))
  .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

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

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

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) | | .groupBy(...cols) | One row per distinct combination (GROUP BY field1, field2) | | .groupAll() | One row over everything (GROUP ALL) | | .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 "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 to exactly 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),
  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(),
  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.*):

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

It aliases itself after the column, matching what SurrealDB already returns for SELECT author_id.*. Pass a trailing alias to override — use that rather than as(), which would append a second AS:

expand(postsTable.author_id, usersTable, "author");
// → author_id.* AS author

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

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.

pickFrom(col, fields) — narrow an embedded object

expand() follows a record link, so it types its result from the target table. An embedded object — or an array of them — has no table to point at, so use pickFrom() instead. The item shape comes from the column itself.

db.from(usersTable).select(
  pick(usersTable.id),
  pickFrom(usersTable.addresses, { city: true, country: true }),
);
// → SELECT id, addresses.{city, country} AS addresses FROM users
// result.addresses typed as { city: string; country: string }[]

It aliases itself after the column, so the result lands under its own name and as() is not needed. An array column stays an array; a single embedded object stays an object:

pickFrom(usersTable.profile, { name: true });
// → profile.{name} AS profile
// result.profile typed as { name: string | null }

Pass a third argument to name it something else. Use that rather than as(), which would append a second AS:

pickFrom(usersTable.addresses, { city: true }, "cities");
// → addresses.{city} AS cities

A nested column aliases to its last segment — pickFrom(usersTable.profile.meta, ...) lands under meta.

Pass a nested map instead of true to descend into an object inside the item, to any depth:

pickFrom(usersTable.addresses, {
  city: true,
  geo: { lat: true, lng: true },
});
// → addresses.{city, geo.{lat, lng}} AS addresses
// result.addresses typed as { city: string; geo: { lat: number; lng: number } }[]

This is the case dot-notation cannot reach: nested field access expands recursively for object() columns but stops at arrays, so the item shape inside array(object(...)) has no column reference of its own.

Note the emitted form is a path followed by a destructure — geo.{lat, lng}. A bare object literal (geo: { lat, lng }) parses in SurrealDB but returns {}.

Nothing is dereferenced here — an embedded value is already present, which is why this is a separate function rather than an overload of expand().

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

db.from(woSessionsExercises)
  .where(eq(woSessionsExercises.woSession, rid("wo_sessions", "abc")))
  .select(
    all(),
    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",
    ),
  );

Generates:

SELECT *,
  exercise.* AS exercise_details,
  (SELECT * FROM exercises_gym_content
   WHERE exercise = $parent.exercise AND gym = gyms:rmgym)[0] AS gym_content
FROM wo_sessions_exercises
WHERE wo_session = wo_sessions:abc

The result type of gym_content is inferred directly from the inner builder — no manual annotation needed.


Aggregates and grouping

Aggregates collapse many rows into one value. Alone they fold the whole result set; with a GROUP BY they fold each group.

import { count, mathSum, mathMean, mathMax, mathMin, as, pick } from "@mateosuarezdev/surrealqb";

| Helper | SurrealQL | Result type | | ---------------- | ------------------- | ------------------------ | | count() | count() | ProjExpr<number> | | count(col) | count(field) | ProjExpr<number> | | mathSum(col) | math::sum(field) | ProjExpr<T> — column's | | mathMax(col) | math::max(field) | ProjExpr<T> — column's | | mathMin(col) | math::min(field) | ProjExpr<T> — column's | | mathMean(col) | math::mean(field) | ProjExpr<number> |

count() counts rows; count(field) counts the rows where that field is truthy, which is how a boolean column is tallied without a WHERE.

mathSum, mathMax and mathMin carry the column's own type through. SurrealDB keeps decimal arithmetic in decimal, and max/min return one of the values they compared, so folding a decimal() column gives you a Decimal and money stays money:

const [totals] = await db
  .from(commissionsTable)
  .select(as(mathSum(commissionsTable.outstanding), "outstanding"))
  .groupAll()
  .collect();

totals.outstanding.add(fee); // Decimal, not number

Wrap each one in as() to name the column. Without it SurrealDB returns the function call itself as the result key, so the property is literally "math::sum(views)".

.groupBy(...cols) — one row per distinct combination

const perAuthor = await db
  .from(postsTable)
  .select(pick(postsTable.author_id), as(mathSum(postsTable.views), "views"))
  .groupBy(postsTable.author_id)
  .collect();
// SELECT author_id, math::sum(views) AS views FROM posts GROUP BY author_id
// perAuthor[0] → { author_id: RecordId; views: number }

SurrealDB requires every grouped field to also be projected, so pick() the group columns and as() the aggregates beside them. That is also what makes the result type come out right — groupBy itself does not change it.

Accepts several columns, accumulates across calls, and takes raw field strings for paths a bound column cannot express. GROUP BY is emitted after WHERE and before ORDER BY, so a sort can name an alias the grouping produced:

db.from(usersTable)
  .where(gt(usersTable.age, 18))
  .select(pick(usersTable.role, usersTable.active), as(count(), "total"))
  .groupBy(usersTable.role, usersTable.active)
  .orderBy("total", "DESC");
// SELECT role, active, count() AS total FROM users WHERE age > $p0
// GROUP BY role, active ORDER BY total DESC

.groupAll() — one row over everything

const [totals] = await db
  .from(postsTable)
  .select(as(mathSum(postsTable.views), "views"), as(count(), "total"))
  .groupAll()
  .collect();
// SELECT math::sum(views) AS views, count() AS total FROM posts GROUP ALL
// totals → { views: number; total: number }

Only the aggregates survive, so the result narrows to the aliases and drops the table's own fields.

Without groupAll() an aggregate is computed per row rather than once, which is rarely the intent — reach for it whenever a query has aggregates and nothing to group by.


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, "[email protected]"))
  .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, "[email protected]"))
  .update()
  .set({ age: subtract(1), tags: append(["verified"]) })
  .collect();

Plain values emit field = $p. Use append(value) for += and subtract(value) for -=.

Both names describe the array case, but they emit SurrealDB's arithmetic operators and apply to anything those operators accept: integers, floats and decimals, durations — including a duration added to a datetime, which moves the timestamp — and collections. Not strings; SurrealQL concatenates with string::concat.

UPDATE subscription:1 SET trial_period += 7d;
UPDATE user:john SET expires_at += 30d;

subtract mirrors it exactly across the same types — decrementing a number, pulling a value out of a collection, or moving a timestamp backwards:

UPDATE user:john SET expires_at -= 7d;
// Adds to a running total in place: settled_amount += $p0
await db
  .from(commissionsTable)
  .where(eq(commissionsTable.id, commissionId))
  .update()
  .set({ settled_amount: append(new Decimal("200")) })
  .collect();

Prefer this over reading a value and writing it back. It is one round trip instead of two, and it closes the window between the read and the write, which a transaction hides but concurrent writers do not.

Delete

// DELETE users WHERE active = $p0 RETURN NONE
await db
  .from(usersTable)
  .where(eq(usersTable.active, false))
  .delete()
  .collect();

This is the normal delete. If it resolves, the deletion succeeded; callers normally already have the record ID they targeted and need no database result.

When the next operation only needs a few fields, project them directly from the deleted records. The result is narrowed to those fields too.

// DELETE users WHERE active = $p0 RETURN id, email
const deleted = await db
  .from(usersTable)
  .where(eq(usersTable.active, false))
  .delete()
  .return(pick(usersTable.id, usersTable.email))
  .collect();

RETURN clause

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

Pass a field projection to return when a mutation needs only part of a record: .return(pick(table.id, table.email)) emits RETURN id, email and narrows the collected result to those fields. Pass one named mode when the full BEFORE, AFTER, DIFF, or NONE behavior is actually needed.

// Get records as they were before deletion when the flow genuinely needs them
const deleted = await db.from(usersTable).where(...).delete().return("BEFORE").collect();

// Return only what the next step needs from an update
const [updated] = await db
  .from(usersTable)
  .where(...)
  .update()
  .merge({ active: false })
  .return(pick(usersTable.id, usersTable.email))
  .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: "[email protected]", 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: "[email protected]", age: 25, role: "user", active: true })
  .collect();

Insert (bulk)

// INSERT INTO users $p0
const created = await db
  .from(usersTable)
  .insert([
    { email: "[email protected]", role: "user" },
    { email: "[email protected]", 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
await db
  .relate(rid("users", "john"), likesTable, rid("posts", "surreal"))
  .content({ created_at: new Date().toISOString() })
  .collect();
// RELATE users:john->likes->posts:surreal CONTENT $p0

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(), 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"), "[email protected]"))
  .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, options?) — throws on failure

Wraps the callback in an SDK-scoped interactive transaction. If the callback or commit fails, SurrealQB attempts cancellation without replacing the original error when cancellation also fails.

await db.transaction(async (tx) => {
  const [user] = await tx
    .from(usersTable)
    .create({ email: "[email protected]", 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.

For a temporary, labeled lifecycle trace, pass { debug: true, label }:

await db.transaction(
  async (tx) => {
    // transactional reads and writes
  },
  { debug: true, label: "paddle:evt_01..." },
);

This logs the begin, callback, commit, and cancel phases. Keep it disabled in normal application operation.

db.safeTransaction(fn, options?) — 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: "[email protected]", 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) | |

Elapsed time between two datetimes

Subtracting two datetimes yields a duration in SurrealQL, so elapsed time is a queryable expression rather than a column the application computes and then has to keep current.

| Helper | SQL | | ---------------------------------------- | ------------------------------- | | elapsedGt(later, earlier, "3d") | (later - earlier) > 3d | | elapsedLt(later, earlier, "3d") | (later - earlier) < 3d | | elapsedEqual(later, earlier, "3d") | (later - earlier) = 3d |

// Orders that took longer than three days to complete
await db
  .from(ordersTable)
  .where(elapsedGt(ordersTable.completed_at, ordersTable.created_at, "3d"))
  .collect();

The duration is inlined rather than bound, unlike every other helper here. A SurrealQL duration is syntax, not a value, so a parameter would arrive as a string and the subtraction would fail. It is typed as DurationLiteral, which accepts a single count and unit as well as a compound chain — 3d, 90m, 1h30m15s500ms, down to nanoseconds. The type requires a valid leading pair and then permits the rest, since a type describing an arbitrarily long chain would reference itself, so it catches a bare number or a missing unit without rejecting any valid duration. Both columns must be datetimes.

elapsedEqual is included for completeness and is rarely what you want: stored timestamps carry sub-second precision and almost never land on a round figure.


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

When raw SQL is itself a named field projection, make that field available to select() with RawSelect:

import { raw, type RawSelect } from "@mateosuarezdev/surrealqb";

db.from(subscriptionsTable).select(
  pick(subscriptionsTable.id),
  raw<RawSelect<{
    items: { provider_price_id: string; billing_plan: { key: string; name: string } }[];
  }>>("items.{provider_price_id, billing_plan.{key, name}}"),
);
// result.items is typed; no `as()` wrapper or runtime join is involved.

RawSelect describes one already-named projection. It does not validate the SurrealQL string, so keep the field name and its declared shape aligned.

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: DateTime }

Use it to annotate function parameters or return types without creating a dummy query:

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

InferResult<T> — element type from a query builder

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

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


JSON API boundaries

InferTable<T> is a server-side result: three of its field types are SDK objects rather than primitives, and none of them survives JSON serialization as an object.

| Server-side (InferTable) | Client-side (ToClient) | Column | | -------------------------- | ------------------------ | ------------- | | RecordId | string | record() | | Decimal | string | decimal() | | DateTime | string | datetime() |

This matters for a framework that infers client types from server return types — BRPC, tRPC, anything end-to-end typed. Without it the client is told it holds a Decimal and calls .mul() on a string, or holds a DateTime and calls .compare() on one. The code compiles and fails in the browser.

So ToClient<T> where only the client-facing type is needed, or wrap the return value with toClient() so inference matches the payload that is actually sent.

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

type User = InferTable<typeof usersTable>;
type ClientUser = ToClient<User>;
// `id`, `commission_rate` and `created_at` are all strings client-side.

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, Decimal and DateTime conversion.

Two forms, and which one fits depends on what is being returned.

For a single query, assert the type. There is one typeof to point at, the value is exactly what it claims, and the assertion reads at the return site as the boundary conversion it is:

const result = await db.from(usersTable).where(...).collect();

return result as unknown as ToClient<typeof result>;

The double assertion is required rather than sloppy: the server and client shapes do not overlap enough for a direct cast, so unknown is the only route between them.

For a composite response, use the function. There is no single typeof to name, so asserting means restating every piece — a second copy of a type the compiler already knows, and wrong the moment one of them changes shape:

return toClient({ subscriptions, balance, partner });

Same client type either way. The first is explicit where explicitness is cheap; the second infers where spelling it out would be a maintenance burden.


Roadmap

Conditions

  • [x] eq, neq, gt, gte, lt, lte
  • [x] and, or, not
  • [x] isNull, isNotNull, isNone
  • [x] inArray / notInArray — field INSIDE $p / field NOT INSIDE $p
  • [x] arrayContains — field CONTAINS $p
  • [x] strContains, strStartsWith, strEndsWith

SELECT

  • [x] .where(), .orderBy(), .limit(), .offset(), .fetch()
  • [x] .byId(id) — SELECT * FROM table:id
  • [x] .groupBy(...cols) / .groupAll() — GROUP BY / GROUP ALL
  • [x] .first() — limit 1, returns single record or undefined
  • [ ] .value(col) — SELECT VALUE field, returns flat scalar array
  • [ ] .omit(...cols) — SELECT * OMIT field
  • [ ] .split(...cols) — SPLIT field
  • [ ] .timeout(duration) — TIMEOUT 5s
  • [ ] .parallel() — PARALLEL keyword
  • [ ] .orderByNumeric() / .orderByCollate() — numeric / locale-aware sort
  • [ ] WITH INDEX / WITH NOINDEX hints

Projections

  • [x] all() — *
  • [x] pick(...cols) — specific fields, narrows result type
  • [x] as(expr, alias) — arbitrary aliased expressions
  • [x] expand(col, table, fields?) — inline record expansion (field.* / field.{a,b})
  • [x] subquery(builder).index(n) — correlated subquery with index
  • [x] Aggregates — count(), count(col), mathSum, mathMean, mathMax, mathMin

Mutations

  • [x] .update().merge(values) — UPDATE ... MERGE $p
  • [x] .update().set(values) — UPDATE ... SET field = $p, with append / subtract
  • [x] .delete() — DELETE ... RETURN BEFORE
  • [x] .return(clause) on update / delete — BEFORE | AFTER | DIFF | NONE
  • [x] .create(values) — CREATE table CONTENT $p
  • [x] .byId().create(values) — CREATE table:id CONTENT $p
  • [x] .insert(values[]) — INSERT INTO table $p
  • [ ] .update().content(values) — full record replacement
  • [ ] .update().unset(...fields) — UNSET field1, field2
  • [ ] .update().patch(ops) — JSON Patch array
  • [ ] .insert().ignore() — INSERT IGNORE INTO
  • [ ] .insert().onDuplicate(values) — ON DUPLICATE KEY UPDATE
  • [ ] UPSERT builder — UPSERT table SET/MERGE/CONTENT $p

Graph

  • [x] db.relate(from, edgeTable, to).content(values) — RELATE a->edge->b
  • [x] right(), left(), both() — outgoing / incoming / bidirectional traversal
  • [x] Chainable multi-hop traversal — right(...).left(...)
  • [ ] Edge filtering — ->(rel WHERE condition)->target
  • [ ] Fixed-depth recursion — ->rel->{n}->target
  • [ ] Depth range — ->rel->{min..max}->target
  • [ ] Shortest path — ->rel->{..shortest=$p}->target

Built-in functions (projection expressions)

  • [ ] String — strLen, strUppercase, strLowercase, strTrim, strSlug, strSplit, strReplace, strSlice, …
  • [ ] Array — arrayLen, arrayFirst, arrayLast, arrayAt, arraySort, arrayDistinct, arrayFlatten, …
  • [ ] Datetime — timeYear, timeMonth, timeDay, timeHour, timeUnix, timeFormat

Infrastructure

  • [x] db.batch(...builders) — single round trip, shared param counter, typed tuple result
  • [x] db.transaction(fn) — BEGIN / COMMIT TRANSACTION, auto-cancel on throw
  • [x] db.safeTransaction(fn) — same but returns [error, result] instead of throwing
  • [x] InferTable<T> / InferResult<T> — type inference helpers
  • [x] .toSQL() on all builders — SQL preview without hitting the database
  • [ ] LIVE SELECT — live query UUID + subscribe/kill API
  • [ ] Schema emitter — emit DEFINE FIELD DDL from table descriptors

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.