@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.
Maintainers
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
- Define a table
- Column types
- SELECT
- Projections
- Aggregates and grouping
- Mutations
- RELATE
- Graph relations
- Nested field access
- Typed record traversal
- Batch queries
- Transactions
- Condition reference
- Raw values
- Type inference helpers
- JSON API boundaries
- Roadmap
- LLM usage
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 postspick(...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 postsexpand(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 shapeIt 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 authorWith 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 citiesA 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:abcThe 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 numberWrap 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 $p0collect() 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 PostRecordGraph 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:abc123parent(col) — parent record reference in subqueries
import { parent } from "@mateosuarezdev/surrealqb";
db.from(gymContentTable).where(
eq(gymContentTable.exercise, parent(exercisesTable.id)),
);
// WHERE exercise = $parent.idraw(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 bindingWhen 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 typeWorks 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 orundefined - [ ]
.value(col)—SELECT VALUE field, returns flat scalar array - [ ]
.omit(...cols)—SELECT * OMIT field - [ ]
.split(...cols)—SPLIT field - [ ]
.timeout(duration)—TIMEOUT 5s - [ ]
.parallel()—PARALLELkeyword - [ ]
.orderByNumeric()/.orderByCollate()— numeric / locale-aware sort - [ ]
WITH INDEX/WITH NOINDEXhints
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, withappend/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 FIELDDDL 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.txtAdjust 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.
