A type-safe TypeScript ORM for SurrealDB that provides full type inference, a fluent query builder, comprehensive CRUD operations, and first-class support for graph relationships, and database functions.
- Type-safe schema definitions - Define your database schema using intuitive
t.*builders - Automatic type inference - Get full TypeScript types without code generation
- Fluent query builder - Chain
.select(),.where(),.return()with full type safety - Complete CRUD operations - SELECT, CREATE, UPDATE, DELETE, and UPSERT queries
- Live queries - Real-time
LIVE SELECTsubscriptions with typed notifications - Graph relationships - First-class support for edges and graph traversal
- Rich type system - Objects, arrays, unions, literals, options, and more
- SurrealDB functions - Integrated string, array, and record operations
bun add surqlize
# or
npm install surqlize- SurrealDB server ≥ 3.0 — surqlize targets the SurrealDB 3.x server. Some
features degrade or are unavailable on older servers; for example, filtering
or projecting a live query relies on query parameters that require server ≥
3.0 (
FETCHrequires ≥ 2.2.0). surrealdbJavaScript SDK ≥ 2.0.0 — declared as a peer dependency, so install it alongside surqlize. (The SDK and the server are versioned independently: the 2.x SDK is what connects to a 3.x server.)- TypeScript ≥ 5.0 for full type inference.
import { Surreal } from "surrealdb";
import { orm, table, t } from "surqlize";
// Define a table schema
const user = table("user", {
name: t.string(),
email: t.string(),
age: t.number(),
created: t.date(),
});
// Create ORM instance
const db = orm(new Surreal(), user);
// Build type-safe queries
const query = db
.select("user")
.where((user) => user.age.gte(18))
.return((user) => ({
name: user.name,
email: user.email,
}));
// TypeScript knows the exact return type!
type Result = t.infer<typeof query>;
// Result: Array<{ name: string; email: string }>Define tables using the table() function with a rich type system:
import { table, t } from "surqlize";
const user = table("user", {
// Basic types
name: t.string(),
age: t.number(),
isActive: t.bool(),
created: t.date(),
userId: t.uuid(),
// Complex objects
address: t.object({
street: t.string(),
city: t.string(),
zipCode: t.string(),
}),
// Arrays
tags: t.array(t.string()),
scores: t.array(t.number()),
// Mixed-type arrays (tuples)
mixedData: t.array([t.string(), t.number(), t.bool()]),
// Optional fields
bio: t.option(t.string()),
// Record references (foreign keys)
authorId: t.record("author"),
// Union types
status: t.union([
t.literal("active"),
t.literal("inactive"),
t.literal("pending"),
]),
// Literals
role: t.literal("admin"),
});Note: Every table automatically includes an id field of type RecordId<TableName>.
Define graph edges to model relationships between tables:
import { edge, table, t } from "surqlize";
const user = table("user", {
name: t.string(),
email: t.string(),
});
const post = table("post", {
title: t.string(),
content: t.string(),
});
// Define an edge from user to post
const authored = edge("user", "authored", "post", {
created: t.date(),
role: t.union([t.literal("author"), t.literal("co-author")]),
});
const db = orm(new Surreal(), user, post, authored);Automatic fields: Edges automatically include:
id: RecordId of the edgein: RecordId of the source table (user)out: RecordId of the target table (post)
Pass your tables and edges to orm() to get a type-safe client. Schemas can be
supplied either as individual arguments or grouped in a single object — both
produce an identical, fully typed ORM:
// As individual arguments
const db = orm(new Surreal(), user, post, authored);
// Or grouped in an object
const db = orm(new Surreal(), { user, post, authored });The object form pairs nicely with a dedicated schema module, letting you define your tables once and reuse them across multiple ORM instances:
// database/schema.ts
import { edge, t, table } from "surqlize";
export const user = table("user", {
name: t.string(),
email: t.string(),
});
export const post = table("post", {
title: t.string(),
content: t.string(),
});
export const authored = edge("user", "authored", "post", {
created: t.date(),
});// src/db.ts
import { Surreal } from "surrealdb";
import { orm } from "surqlize";
import * as schema from "../database/schema";
const db = orm(new Surreal(), schema);Tables are always addressed by their
tbname in queries (e.g.db.select("user")), regardless of how they are registered. The object keys are purely organizational.
// Select all records
const allUsers = db.select("user");
// Select with WHERE clause
const adults = db
.select("user")
.where((user) => user.age.gte(18));
// Project specific fields with RETURN
const userNames = db
.select("user")
.return((user) => ({
fullName: user.name,
email: user.email,
}));
// Pagination
const paginatedUsers = db
.select("user")
.start(10)
.limit(20);
// Select a single record by ID (returns array with 0 or 1 item)
const specificUser = await db.select(new RecordId("user", "john"));
// To get the first item, use .val() or .at(0):
const specificUser = await db.select(new RecordId("user", "john")).then.val();
// Or get a specific item:
const specificUser = await db.select(new RecordId("user", "john")).then.at(0);
// Nested queries (JOIN-like)
const postsWithAuthors = db.select("post").return((post) => ({
title: post.title,
author: post.authorId.select().return((author) => ({
name: author.name,
email: author.email,
})),
}));// Order by single field
const sorted = db.select("user")
.orderBy("age", "DESC");
// Order by multiple fields
const multiSort = db.select("user")
.orderBy("lastName", "ASC")
.orderBy("firstName", "ASC");
// Order by with callback (for nested fields)
const nestedSort = db.select("user")
.orderBy(user => user.name.last, "ASC");
// Numeric sorting
const numericSort = db.select("user")
.orderByNumeric("age", "DESC");
// Collation sorting
const collateSort = db.select("user")
.orderByCollate("name", "ASC");// Group by field(s)
const grouped = db.select("post")
.groupBy("author");
// Group all (for table-wide aggregates)
const totalCount = db.select("user")
.groupAll();// Fetch linked records
const withAuthor = db.select("post")
.fetch("author");
// Fetch multiple relations
const deepFetch = db.select("post")
.fetch("author", "comments");
// Project a subset of a fetched relation with RETURN. The result is typed and
// validated against the projected shape, so you can pick fields from the linked
// records (in / out) and omit the edge's own fields without errors.
const authorships = db.select("authored")
.fetch("in", "out")
.return((edge) => ({
user: edge.in.name, // from the fetched `user` record
post: edge.out.title, // from the fetched `post` record
role: edge.role,
}));fetch also follows nested record paths. Like SurrealDB, fetching a nested
path expands every record link along the way, and the result type is resolved
accordingly — out becomes the linked record's object, and out.author
expands the author link inside it:
// purchased is an edge: user ->purchased-> product, and product.author -> author
const purchases = await db.select("purchased")
.fetch("out", "out.author")
.execute();
purchases[0].out.title; // string (product fetched)
purchases[0].out.author.name; // string (nested author fetched)
purchases[0].in; // RecordId<"user"> (left as a link)Record links wrapped in option<…> or array<…> are resolved too, so
fetch("tags") on an array<record<tag>> field yields an array of full tag
objects.
// Split array field into multiple records
const splitTags = db.select("post")
.split("tags");
// Split multiple arrays
const multiSplit = db.select("post")
.split("tags", "categories");// Set timeout duration
const withTimeout = db.select("user")
.where(user => user.age.gt(18))
.timeout("5s");// Complex query with multiple clauses
const complexQuery = db.select("post")
.where(post => post.title.startsWith("Hello"))
.split("tags")
.orderBy("created", "DESC")
.limit(20)
.fetch("author")
.timeout("10s");Create a new record with a specific id or a generated id.
// Create with SET
const newUser = await db.create("user").set({
name: "Alice",
email: "alice@example.com",
age: 30,
created: new Date(),
});
// Create with CONTENT
const newPost = await db.create("post").content({
title: "Hello World",
body: "First post!",
authorId: new RecordId("user", "alice"),
published: true,
});
// Create with explicit ID
const user = await db.create("user", "alice123").set({
name: "Alice",
email: "alice@example.com",
});
// Control return value
const created = await db.create("user")
.set({ name: "Bob" })
.return("after"); // or "before", "none", "diff"Insert one or multiple records with support for bulk operations and conflict handling.
// Insert single record (object style)
await db.insert("user", {
name: "Alice",
email: "alice@example.com",
age: 30,
});
// Bulk insert (object style)
await db.insert("user", [
{ name: "Alice", email: "alice@example.com", age: 30 },
{ name: "Bob", email: "bob@example.com", age: 25 },
{ name: "Charlie", email: "charlie@example.com", age: 28 },
]);
// VALUES tuple syntax
await db.insert("user")
.fields(["name", "email", "age"])
.values(
["Alice", "alice@example.com", 30],
["Bob", "bob@example.com", 25]
);
// IGNORE duplicates (skip conflicts silently)
await db.insert("user", userData).ignore();
// ON DUPLICATE KEY UPDATE (update on conflict)
await db.insert("user", {
id: "alice",
name: "Alice",
age: 30
})
.onDuplicate({
age: { "+=": 1 },
lastSeen: new Date(),
});
// With operators in ON DUPLICATE
await db.insert("post", posts)
.onDuplicate({
views: { "+=": 1 },
tags: { "+=": ["updated"] },
});
// With RETURN clause
const inserted = await db.insert("user", data).return("after");
// With RETURN projection
const insertedNames = await db.insert("user", data)
.return(u => ({ name: u.name }));Create a record if it doesn't exist, update records if matching records exist.
// Upsert with SET
await db.upsert("user", "alice")
.set({
name: "Alice",
email: "alice@example.com",
age: 30,
});
// Upsert with operators (atomic increment)
await db.upsert("pageview", "homepage")
.set({
count: { "+=": 1 },
lastViewed: new Date(),
});
// Upsert with MERGE
await db.upsert("user", "alice")
.merge({ lastLogin: new Date() });
// Bulk upsert with WHERE
await db.upsert("user")
.where((u) => u.email.eq("alice@example.com"))
.set({ lastSeen: new Date() });Update a record or multiple records in a table.
// Update with SET
await db.update("user", "alice")
.set({ age: 31 });
// Bulk update with WHERE
await db.update("user")
.where((u) => u.age.lt(18))
.set({ status: "minor" });
// Array and number operators
await db.update("user", "alice")
.set({
age: { "+=": 1 }, // Increment
tags: { "+=": ["developer"] }, // Append to array
oldTags: { "-=": ["beginner"] }, // Remove from array
});
// CONTENT (replace entire record)
await db.update("user", "alice")
.content({
name: "Alice Smith",
email: "alice@example.com",
age: 31,
});
// MERGE (partial update)
await db.update("user", "alice")
.merge({ email: "newemail@example.com" });
// PATCH (JSON Patch operations)
await db.update("user", "alice")
.patch([
{ op: "replace", path: "/age", value: 32 },
{ op: "remove", path: "/oldField" },
]);
// UNSET (remove fields)
await db.update("user", "alice")
.set({ name: "Alice" })
.unset(["oldField1", "oldField2"]);
// Return modified records
const updated = await db.update("user")
.where((u) => u.age.gt(65))
.set({ status: "senior" })
.return("after");Create graph edges between records using defined edge schemas.
// Single edge between two records
const edge = await db.relate(
"authored",
new RecordId("user", "alice"),
new RecordId("post", "hello-world")
);
// With edge data using content()
const friendship = await db.relate(
"knows",
new RecordId("user", "user1"),
new RecordId("user", "user2")
).content({
since: new Date(),
strength: 5,
});
// With edge data using set()
const likes = await db.relate(
"likes",
new RecordId("user", "userId"),
new RecordId("post", "postId")
).set({
created: new Date(),
rating: 5,
});
// Cartesian product: create multiple edges
// Creates: alice->authored->post1, alice->authored->post2,
// bob->authored->post1, bob->authored->post2
const edges = await db.relate(
"authored",
[new RecordId("user", "alice"), new RecordId("user", "bob")],
[new RecordId("post", "post1"), new RecordId("post", "post2")]
);
// Control return mode
await db.relate(
"authored",
new RecordId("user", "user"),
new RecordId("post", "post")
).content({ created: new Date() })
.return("after"); // or "before", "none", "diff"
// With return projection
const edgeInfo = await db.relate(
"follows",
new RecordId("user", "follower"),
new RecordId("user", "followee")
).set({ since: new Date() })
.return(edge => ({
id: edge.id,
from: edge.in,
to: edge.out,
since: edge.since,
}));
// Using with query results
const userQuery = db.select("user", "alice");
const postQuery = db.select("post", "hello");
await db.relate("authored", userQuery, postQuery);// Delete single record (returns array with 0 or 1 item)
await db.delete("user", "alice");
// Bulk delete with WHERE
await db.delete("user")
.where((u) => u.age.lt(13));
// Return deleted records
const deleted = await db.delete("user")
.where((u) => u.status.eq("inactive"))
.return("before");
// Delete with projection
const deletedNames = await db.delete("user")
.where((u) => u.email.endsWith("@spam.com"))
.return((u) => ({ name: u.name }));Execute multiple queries as a single atomic operation in one round-trip. No intermediate results are available — all queries succeed or all fail together.
// Multiple queries in a single atomic operation
const [user, updated, allUsers] = await db.batch(
db.create("user").set({ name: "Alice", age: 30 }),
db.update("user", "bob").set({ age: 31 }),
db.select("user"),
);
// Results are fully typed as a tupleYou can also inspect the generated SurrealQL before executing:
const b = db.batch(
db.create("user").set({ name: "Alice" }),
db.update("user", "bob").set({ age: 31 }),
);
console.log(b.toString());
// BEGIN TRANSACTION; CREATE user SET name = $_v0; UPDATE user:bob SET age = $_v1; COMMIT TRANSACTION;
// Execute when ready
const [created, updated] = await b;Open a server-side transaction, execute queries one-by-one with intermediate results, and decide whether to commit or cancel based on the outcomes.
The callback form automatically commits on success and cancels on error:
const result = await db.transaction(async (tx) => {
const user = await tx.create("user").set({
name: "Alice",
age: 30,
});
// Use intermediate results to make decisions
if (user.age > 25) {
await tx.update("user", user.id).set({ status: "senior" });
}
return user;
});
// Transaction is committed automaticallyFor full control, use the manual form:
const tx = await db.transaction();
try {
const user = await tx.create("user").set({ name: "Alice" });
await tx.relate("authored", user.id, new RecordId("post", "hello"));
await tx.commit();
} catch (e) {
await tx.cancel();
throw e;
}The transaction object (tx) has all the same query-builder methods as the main db instance — select, create, insert, update, upsert, delete, and relate.
Subscribe to real-time changes with db.live(). It compiles to a SurrealDB LIVE SELECT statement and resolves to a typed LiveSubscription:
// Open a subscription to every change on the `user` table
const sub = await db.live("user");
// Handle notifications (returns a function that unsubscribes this handler)
const off = sub.subscribe((msg) => {
// msg.action: "CREATE" | "UPDATE" | "DELETE" | "KILLED"
// msg.recordId: the affected RecordId
// msg.value: the affected record, parsed against the schema
console.log(msg.action, msg.value);
});
// Stop receiving updates on this handler...
off();
// ...or kill the subscription entirely
await sub.kill();The notification value is parsed against the table schema, so it is fully typed:
const sub = await db.live("user");
sub.subscribe((msg) => {
msg.value.name.first; // string
msg.value.age; // number
});A subscription is also async-iterable:
for await (const msg of await db.live("user")) {
console.log(msg.action, msg.value);
}Live queries support .where(), .return() (a VALUE projection) and .fetch(), mirroring select:
// Only notify for adult users
const adults = await db.live("user").where((user) => user.age.gte(18));
// Project a subset of fields
const names = await db.live("user").return((user) => ({
name: user.name,
email: user.email,
}));
// Resolve record links in notifications
const posts = await db.live("post").fetch("author");Note: Filtering or projecting a live query relies on query parameters, which require SurrealDB ≥ 3.0 (
FETCHrequires ≥ 2.2.0). On older servers a parameterized live query is accepted but never delivers notifications.
Use .diff() to receive JSON Patch arrays instead of full records (LIVE SELECT DIFF):
const sub = await db.live("user").diff();
sub.subscribe((msg) => {
// msg.value is a JsonPatchOp[]
});Calling .subscribe(handler) on the builder starts the query and returns a stop function that both unsubscribes and kills the subscription:
const stop = await db
.live("user")
.where((user) => user.age.gte(18))
.subscribe((msg) => console.log(msg.action, msg.value));
// later
stop();Live subscriptions are unmanaged — they are not automatically restarted if the connection drops and reconnects. The table must also exist before subscribing (define it up front, e.g. with
DEFINE TABLE).
All queries in Surqlize return arrays, even when selecting by a specific record ID. To access the first item from a query result, use .val() or .at(index):
// .val() - Returns the first item or undefined
const user = await db.select("user", "alice").then.val();
// user: User | undefined
// .at(index) - Returns the item at the specified index or undefined
const firstUser = await db.select("user").then.at(0);
const secondUser = await db.select("user").then.at(1);
const lastUser = await db.select("user").then.at(-1); // negative indexing supported
// Working with arrays directly
const users = await db.select("user", "alice");
// users: User[]
if (users.length > 0) {
const user = users[0];
}
// Use with update, delete, and upsert
const updated = await db.update("user", "alice")
.set({ age: 31 })
.return("after")
.then.val();
const deleted = await db.delete("user", "alice")
.return("before")
.then.val();All types support these comparison operators:
db.select("user").where((user) =>
// Equality
user.name.eq("John") // =
user.age.ne(25) // !=
user.email.ex("john@example.com") // == (exact match)
// Comparison
user.age.gt(18) // >
user.age.gte(21) // >=
user.age.lt(65) // <
user.age.lte(64) // <=
// Array membership
user.status.inside(["active", "pending"]) // IN
user.status.notInside(["banned", "deleted"]) // NOT IN
// Logical operators
user.age.gte(18).and(user.isActive.eq(true))
user.role.eq("admin").or(user.role.eq("moderator"))
user.isActive.not()
// Truthiness checks
user.bio.trueish() // !! (double negation - checks for truthy value)
user.archived.falseish() // ! (negation - checks for falsy value)
);For complex conditions, use the standalone and() and or() combiners. These make precedence explicit and produce correctly parenthesized SurrealQL:
import { orm, table, t, and, or } from "surqlize";
// Simple compound: age >= 18 AND email ends with @example.com
db.select("user").where((user) =>
and(user.age.gte(18), user.email.endsWith("@example.com"))
);
// WHERE (age >= 18 AND string::ends_with(email, "@example.com"))
// OR with multiple options
db.select("user").where((user) =>
or(user.role.eq("admin"), user.role.eq("moderator"), user.role.eq("owner"))
);
// WHERE (role = "admin" OR role = "moderator" OR role = "owner")
// Nested: AND with inner OR for grouped conditions
db.select("user").where((user) =>
and(
user.age.gte(18),
or(user.role.eq("admin"), user.role.eq("moderator")),
user.email.endsWith("@example.com"),
)
);
// WHERE (age >= 18 AND (role = "admin" OR role = "moderator") AND string::ends_with(email, "@example.com"))
// Chaining .and() / .or() on individual conditions also works
db.select("user").where((user) =>
user.age.gte(18).and(user.name.first.eq("Alice"))
);
// WHERE (age >= 18 AND name.first = "Alice")Both and() and or() require at least two conditions and accept any number of additional conditions. Nesting them produces correctly parenthesized output, so precedence is always explicit.
db.select("user").where((user) =>
user.email.startsWith("admin@")
user.name.endsWith("son")
user.email.contains("@example.com")
user.email.isEmail()
);
db.select("user").return((user) => ({
fullName: user.firstName.join(" ", user.lastName),
nameLength: user.name.len(),
upper: user.name.uppercase(),
lower: user.email.lowercase(),
trimmed: user.name.trim(),
slug: user.name.slug(),
words: user.name.words(),
reversed: user.name.reverse(),
replaced: user.email.replace("@old.com", "@new.com"),
parts: user.email.split("@"),
}));Additional string functions include capitalize, repeat, slice, matches, distance functions (distanceLevenshtein, distanceHamming, etc.), HTML functions (htmlEncode, htmlSanitize), validation (isUrl, isDomain, isUuid, etc.), semver operations, and similarity scoring.
db.select("user").where((user) =>
// Single element checks
user.tags.contains("typescript") // Array contains element
user.tags.containsNot("java") // Array doesn't contain element
// Multiple element checks
user.tags.containsAll(["javascript", "typescript"]) // Contains all elements
user.tags.containsAny(["rust", "go", "python"]) // Contains any element
user.tags.containsNone(["php", "perl"]) // Contains none of elements
// Inside checks (array subset operations)
user.tags.allInside(allowedTags) // All elements are in allowedTags
user.tags.anyInside(popularTags) // Any element is in popularTags
user.tags.noneInside(bannedTags) // No elements are in bannedTags
// Empty check
user.tags.isEmpty() // Array is empty
);
db.select("post").return((post) => ({
title: post.title,
firstTag: post.tags.at(0), // Get element at index
tagCount: post.tags.len(), // Array length
first: post.tags.first(), // First element
last: post.tags.last(), // Last element
sorted: post.tags.sort(), // Sort array
unique: post.tags.distinct(), // Unique values
flat: post.tags.flatten(), // Flatten nested arrays
reversed: post.tags.reverse(), // Reverse array
}));Additional array functions include mutation (add, append, prepend, push, pop, insert, remove, fill, swap), set operations (combine, complement, concat, difference, intersect, union, transpose), boolean operations (booleanAnd, booleanOr, logicalAnd, etc.), and search functions (findIndex, filterIndex, max, min).
db.select("user").return((user) => ({
absAge: user.age.abs(),
rounded: user.age.round(),
ceiling: user.age.ceil(),
floored: user.age.floor(),
squareRoot: user.age.sqrt(),
squared: user.age.pow(2),
fixed: user.age.fixed(2),
clamped: user.age.clamp(0, 100),
radians: user.age.deg2rad(),
sine: user.age.sin(),
cosine: user.age.cos(),
naturalLog: user.age.ln(),
log10: user.age.log10(),
}));Additional number functions include tan, cot, acos, asin, atan, acot, log, log2, rad2deg, sign, lerp, lerpangle.
db.select("user").return((user) => ({
year: user.created.year(),
month: user.created.month(),
day: user.created.day(),
hour: user.created.hour(),
minute: user.created.minute(),
second: user.created.second(),
weekDay: user.created.wday(),
dayOfYear: user.created.yday(),
unix: user.created.unix(),
millis: user.created.millis(),
formatted: user.created.format("%Y-%m-%d"),
isLeap: user.created.isLeapYear(),
}));Additional date functions include week, micros, nano, and rounding functions (timeCeil, timeFloor, timeRound).
When working with optional values (created with t.option()), you can use map() to transform the value if it exists:
const user = table("user", {
name: t.string(),
bio: t.option(t.string()),
});
db.select("user").return((user) => ({
name: user.name,
// Transform bio to uppercase if it exists
bioUpper: user.bio.map((b) => b.toUpperCase()),
// Chain multiple operations
bioLength: user.bio.map((b) => b.len()),
}));When you have a record reference, you can perform nested queries:
const post = table("post", {
title: t.string(),
authorId: t.record("user"),
});
// Nested query with .select()
const query = db.select("post").return((post) => ({
title: post.title,
author: post.authorId.select().return((author) => ({
name: author.name,
email: author.email,
})),
}));
// TypeScript infers the complete nested type!
type Result = t.infer<typeof query>;
// Result: Array<{
// title: string;
// author: { name: string; email: string } | undefined;
// }>Standalone functions are not called on a field but used independently within query callbacks. Functions with value parameters extract the query context automatically from the first value. Zero-arg functions and constants require an explicit context source (any Workable from the callback).
import { count, math, time, crypto, rand, parse } from "surqlize";
// Count and aggregation
db.select("user")
.groupAll()
.return((user) => ({
total: count(user),
adults: count(user, user.age.gte(18)),
avgAge: math.mean(user.age),
totalAge: math.sum(user.age),
maxAge: math.max(user.age),
}));
// Time and crypto
db.select("user").return((user) => ({
now: time.now(user),
emailHash: crypto.sha256(user.email),
randomId: rand.uuid(user),
emailDomain: parse.emailHost(user.email),
}));
// Math constants (zero-arg, need context source)
db.select("user").return((user) => ({
pi: math.pi(user),
e: math.e(user),
tau: math.tau(user),
}));Available standalone function families: count, math (aggregation + constants), time, crypto, rand, duration, type_, encoding, geo, http, meta, object, parse, search, session, set_, sleep, value, vector, bytes, not.
Control what gets returned from mutations:
// Return nothing
await db.update("user", "alice").set({ age: 31 }).return("none");
// Return state before modification
const before = await db.update("user", "alice")
.set({ age: 31 })
.return("before");
// Return state after modification (default)
const after = await db.update("user", "alice")
.set({ age: 31 })
.return("after");
// Return diff of changes
const diff = await db.update("user", "alice")
.set({ age: 31 })
.return("diff");
// Return specific fields with projection
const projection = await db.update("user", "alice")
.set({ age: 31, email: "new@email.com" })
.return((u) => ({ name: u.name, age: u.age }));const users = await db.select("user")
.where((u) => u.age.gt(18))
.timeout("5s");
await db.update("user", "alice")
.set({ age: 31 })
.timeout("10s");Use operators for atomic operations:
// Increment/decrement numbers
db.update("user", "alice").set({
age: { "+=": 1 },
score: { "-=": 10 },
});
// Add/remove from arrays
db.update("post", "post1").set({
tags: { "+=": ["typescript", "database"] },
oldTags: { "-=": ["deprecated"] },
});Extract TypeScript types from your queries using t.infer<>:
// Infer query result type
const query = db.select("user").return((user) => ({
name: user.name,
age: user.age,
}));
type QueryResult = t.infer<typeof query>;
// QueryResult: Array<{ name: string; age: number }>
// Infer table row type
const userTable = table("user", {
name: t.string(),
age: t.number(),
});
type User = (typeof userTable)["type"];
// User: { id: RecordId<"user">; name: string; age: number }
// Infer individual type definitions
const emailType = t.string();
type Email = t.infer<typeof emailType>;
// Email: stringInspect generated SurrealQL:
import { displayContext, __display } from "surqlize";
const query = db.select("user").where((u) => u.age.gte(18));
const ctx = displayContext();
const sql = query[__display](ctx);
console.log(sql); // Generated SurrealQL
console.log(ctx.variables); // Parameterized valuesTraverse graph edges directly inside queries. Call .out() on a select row to
follow an edge to the far table (->edge->target), or .in() to follow it in
reverse (<-edge<-source). TypeScript only permits edges that actually connect
to the current table, and the result type is inferred automatically.
You can traverse from the row itself (user.out("authored"), rooted at the
row's id) or from any record-link field (post.author.out(...)).
const user = table("user", { name: t.string() });
const post = table("post", { title: t.string() });
const tag = table("tag", { label: t.string() });
const authored = edge("user", "authored", "post", {
created: t.date(),
role: t.string(),
});
const tagged = edge("post", "tagged", "tag", {});
const db = orm(new Surreal(), user, post, tag, authored, tagged);Chain .select().return() to project the records a traversal lands on. The
traversal compiles to a subquery:
const usersWithPosts = db.select("user").return((user) => ({
name: user.name,
posts: user
.out("authored") // ->authored->post
.select()
.return((post) => ({ title: post.title })),
}));
// posts is typed as { title: string }[]Used directly in a projection, a traversal yields an array of record links —
exactly like raw SurrealQL ->authored->post:
const query = db.select("user").return((user) => ({
postIds: user.out("authored"),
}));
type Result = t.infer<typeof query>;
// Result: Array<{ postIds: RecordId<"post">[] }>Steps chain, with every hop re-typed against the table it lands on:
db.select("user").return((user) => ({
tags: user
.out("authored") // -> post
.out("tagged") // -> tag
.select()
.return((tag) => ({ label: tag.label })),
}));
// ->authored->post->tagged->tag// Who authored this post?
db.select("post").return((post) => ({
authors: post
.in("authored") // <-authored<-user
.select()
.return((user) => ({ name: user.name })),
}));.outEdge() / .inEdge() stop on the edge itself, so you can read its own
fields (such as created or role):
db.select("user").return((user) => ({
authorships: user.outEdge("authored").select().return((e) => ({
when: e.created,
role: e.role,
})),
}));
// ->authoredFilter on the edge mid-traversal with where — this compiles to
->(edge WHERE …)->target, and the callback receives the edge's fields:
db.select("user").return((user) => ({
posts: user
.out("authored", { where: (e) => e.role.eq("author") })
.select()
.return((post) => ({ title: post.title })),
}));
// ->(authored WHERE role = "author")->postTraversals also compose inside WHERE to filter the outer query. len() and
isEmpty() are the idiomatic "has any" / "has none" checks:
// Users who have authored at least one post
db.select("user").where((user) => user.out("authored").len().gt(0));
// Users who have authored none
db.select("user").where((user) => user.out("authored").isEmpty());Repeat a hop with depth to walk several levels deep — this compiles to
SurrealDB's recursive idiom record.{depth}(->edge->target) and returns the
records reached at the deepest level. depth is an exact number, an inclusive
[min, max] range, or { min?, max? } for open-ended ranges:
const person = table("person", { name: t.string() });
const knows = edge("person", "knows", "person", {});
const db = orm(new Surreal(), person, knows);
// Connections between one and three hops away
db.select("person").return((p) => ({
network: p.out("knows", { depth: [1, 3] }),
}));
// person.{1..3}(->knows->person)Add collect to gather every unique node encountered, or shortest to find the
shortest path to a target record:
// Everyone reachable through `knows`
db.select("person").return((p) => ({
reachable: p.out("knows", { collect: true }), // .{..+collect}(->knows->person)
}));
// Shortest path from one person to another (the target binds as a parameter)
db.select("person", "alice").return((p) => ({
path: p.out("knows", { shortest: new RecordId("person", "dave") }),
}));
// person:alice.{..+shortest=$target}(->knows->person)depth / collect / shortest compose with edge filtering (where) too.
The lookup maps expose which edges connect which tables at runtime:
db.lookup.to; // { user: ["authored"], post: ["tagged"], tag: [], ... }
db.lookup.from; // { post: ["authored"], tag: ["tagged"], user: [], ... }Here's a complete example showcasing multiple features:
const user = table("user", {
name: t.object({
first: t.string(),
last: t.string(),
}),
age: t.number(),
email: t.string(),
tags: t.array(t.string()),
bio: t.option(t.string()),
});
const post = table("post", {
title: t.string(),
content: t.string(),
authorId: t.record("user"),
created: t.date(),
});
const authored = edge("user", "authored", "post", {
created: t.date(),
});
const db = orm(new Surreal(), user, post, authored);
// Complex query with nested data and string operations
const query = db
.select("post")
.where((post) =>
post.title.startsWith("Guide").and(
post.created.gte(new Date("2024-01-01"))
)
)
.return((post) => ({
title: post.title,
author: post.authorId.select().return((author) => ({
fullName: author.name.first.join(" ", author.name.last),
age: author.age,
hasBio: author.bio.trueish(),
})),
}))
.orderBy("created", "DESC")
.limit(10);
// Fully typed result
type Result = t.infer<typeof query>;
// Fetch resolves record references into full objects
const posts = await db
.select("post")
.fetch("authorId")
.execute();
// posts[0].authorId is now the full user object, not a RecordIdSurqlize accepts any SurrealSession (or Surreal, which extends it), enabling multiple ORM instances scoped to different sessions over a single connection. Each session maintains its own namespace, database, authentication state, and variables.
import { Surreal } from "surrealdb";
import { orm, table, t } from "surqlize";
const user = table("user", { name: t.string(), age: t.number() });
const surreal = new Surreal();
await surreal.connect("ws://localhost:8000");
await surreal.signin({ username: "root", password: "root" });
// Create separate sessions for different tenants
const tenantA = await surreal.newSession();
await tenantA.signin({ username: "root", password: "root" });
await tenantA.use({ namespace: "app", database: "tenant_a" });
const tenantB = await surreal.newSession();
await tenantB.signin({ username: "root", password: "root" });
await tenantB.use({ namespace: "app", database: "tenant_b" });
// Same schema, same connection, different databases
const dbA = orm(tenantA, user);
const dbB = orm(tenantB, user);
await dbA.create("user").set({ name: "Alice", age: 30 });
await dbB.create("user").set({ name: "Bob", age: 25 });Use forkSession() to clone an existing session (inheriting its namespace, database, auth, and variables) and then diverge:
const surreal = new Surreal();
await surreal.connect("ws://localhost:8000");
await surreal.signin({ username: "root", password: "root" });
await surreal.use({ namespace: "app", database: "main" });
// Fork inherits namespace, database, auth, and variables
const session = await surreal.forkSession();
await session.authenticate(userToken);
const db = orm(session, user);
const users = await db.select("user");
// Clean up when done
await session.closeSession();Since SurrealSession implements Symbol.asyncDispose, sessions work with await using for automatic cleanup:
{
await using session = await surreal.forkSession();
await session.authenticate(userToken);
const db = orm(session, user);
const users = await db.select("user");
// session automatically disposed when scope exits
}| Feature | Surqlize | SurrealDB.js | Prisma | Drizzle | TypeORM |
|---|---|---|---|---|---|
| SurrealDB support | ✅ | ✅ | ❌ | ❌ | ❌ |
| Schema definition | ✅ Code-first | ❌ | ✅ Schema file | ✅ Code-first | |
| Type inference | ✅ Full | ✅ With codegen | ✅ Full | ||
| CRUD operations | ✅ All operations | ✅ | ✅ | ✅ | ✅ |
| Graph and edges | ✅ Native | ✅ Manual | ❌ | ❌ | ❌ |
| Query builder | ✅ Type-safe | ✅ Type-safe | ✅ Query builder | ||
| Database Functions | ✅ Integrated | ✅ SQL functions | ✅ Query functions | ||
| Nested Queries | ✅ Type-safe | ✅ Relations | ✅ Joins | ✅ Relations | |
| Fluent API | ✅ | ❌ | ❌ | ✅ | ✅ |
Why Surqlize?
- Native SurrealDB support: Built specifically for SurrealDB's unique features including graph relationships, flexible schemas, and SurrealQL
- No code generation: Full type inference using TypeScript's type system—no codegen required
- Fluent API: Natural, chainable syntax that mirrors SurrealQL while providing complete type safety
- Graph-first: Edges and relationships are first-class citizens, not an afterthought
- Complete CRUD: Full support for SELECT, CREATE, UPSERT, UPDATE, RELATE, and DELETE operations
This project is in active development. Planned features include:
- SurrealDB functions - All 25 built-in function families (string, array, math, time, crypto, rand, and more)
- Advanced query clauses - ORDER BY, GROUP BY, FETCH, SPLIT
- Transaction support - Batch and interactive transactions
- Multi-session support - Multiple sessions over a single connection
- Live queries - Real-time
LIVE SELECTsubscriptions with typed notifications - Runtime validation - Validate data at runtime using schema definitions
- Graph traversal - Type-safe
.out()/.in()edge navigation, multi-hop chaining, edge-field access, and edge filtering - Advanced graph traversal - Recursive depth ranges, node collection (
collect), and shortest-path finding (shortest) - Performance optimizations - Query caching, connection pooling
- Schema migrations - Version control for database schemas
- Documentation site - Comprehensive guides and API reference
# Install dependencies
bun install
# Build the project
bun run build
# Run the example file
bun run examples/demo.ts
# Run tests
bun run test:unit # Unit tests
bun run test:integration # Integration tests (requires SurrealDB)
bun run type-check # TypeScript type checking
# Lint and format
bun run qc # Check for issues
bun run qa # Auto-fix issues
bun run qau # Auto-fix with unsafe changesContributions are welcome! The project is stabilizing toward a 1.0.0 release; the public API is largely settled, but minor breaking changes are still possible before then. If you'd like to contribute:
- Open an issue to discuss your idea
- Fork the repository
- Create a feature branch
- Submit a pull request
Please ensure your code passes the linting checks (bun run qc).
Apache-2.0
Built with ❤️ for the SurrealDB community