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 NOINDEXhints
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.