Skip to content

surrealdb/surqlize

Repository files navigation


 

A type-safe TypeScript ORM for SurrealDB.


   

     

Surqlize

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.

Features

  • 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 SELECT subscriptions 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

Installation

bun add surqlize
# or
npm install surqlize

Requirements

  • 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 (FETCH requires ≥ 2.2.0).
  • surrealdb JavaScript 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.

Quick start

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

Schema definition

Tables

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

Edges and graph relations

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 edge
  • in: RecordId of the source table (user)
  • out: RecordId of the target table (post)

Registering schemas

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 tb name in queries (e.g. db.select("user")), regardless of how they are registered. The object keys are purely organizational.

CRUD Operations

SELECT statements

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

Sorting with ORDER BY

// 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");

Grouping with GROUP BY

// Group by field(s)
const grouped = db.select("post")
  .groupBy("author");

// Group all (for table-wide aggregates)
const totalCount = db.select("user")
  .groupAll();

Loading relations with FETCH

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

Splitting arrays with SPLIT

// Split array field into multiple records
const splitTags = db.select("post")
  .split("tags");

// Split multiple arrays
const multiSplit = db.select("post")
  .split("tags", "categories");

Setting query timeout

// Set timeout duration
const withTimeout = db.select("user")
  .where(user => user.age.gt(18))
  .timeout("5s");

Combining clauses

// 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 statements

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 statements

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

UPSERT statements

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 statements

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");

RELATE statements

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 statements

// 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 }));

Batch

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 tuple

You 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;

Transactions

Open a server-side transaction, execute queries one-by-one with intermediate results, and decide whether to commit or cancel based on the outcomes.

Callback form (auto-commit/cancel)

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 automatically

Manual form (explicit commit/cancel)

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

Live queries

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

Filtering, projecting and fetching

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 (FETCH requires ≥ 2.2.0). On older servers a parameterized live query is accepted but never delivers notifications.

Diffs

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[]
});

Subscribe in one step

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

Accessing Single Records

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

Filtering operations

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)
);

Compound conditions

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.

Type-specific functions

String functions

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.

Array functions

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

Number functions

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.

Date functions

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

Option functions

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

Record functions

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

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.

Advanced Features

Return Clauses

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

Query Timeouts

const users = await db.select("user")
  .where((u) => u.age.gt(18))
  .timeout("5s");

await db.update("user", "alice")
  .set({ age: 31 })
  .timeout("10s");

Operators

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

Type inference

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: string

Debugging Queries

Inspect 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 values

Graph traversal

Traverse 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);

Materialising records with .select()

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 }[]

Bare traversals return record links

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">[] }>

Multi-hop chaining

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

Incoming edges

// Who authored this post?
db.select("post").return((post) => ({
  authors: post
    .in("authored")               // <-authored<-user
    .select()
    .return((user) => ({ name: user.name })),
}));

Reading edge fields

.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,
  })),
}));
// ->authored

Filtering traversals

Filter 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")->post

Traversals 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());

Recursive traversal & path finding

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.

Edge adjacency metadata

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: [], ... }

Complex example

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 RecordId

Multi-session support

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

Multiple databases over one connection

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

Forking sessions

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

Disposable sessions

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
}

Comparison with other ORMs

Feature Surqlize SurrealDB.js Prisma Drizzle TypeORM
SurrealDB support
Schema definition Code-first Schema file Code-first ⚠️ Decorators
Type inference Full ⚠️ Partial With codegen Full ⚠️ Decorators
CRUD operations All operations
Graph and edges Native Manual
Query builder Type-safe ⚠️ Manual ⚠️ Limited Type-safe Query builder
Database Functions Integrated ⚠️ Manual ⚠️ Limited SQL functions Query functions
Nested Queries Type-safe ⚠️ Manual 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

Roadmap

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 SELECT subscriptions 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

Development

# 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 changes

Contributing

Contributions 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:

  1. Open an issue to discuss your idea
  2. Fork the repository
  3. Create a feature branch
  4. Submit a pull request

Please ensure your code passes the linting checks (bun run qc).

License

Apache-2.0


Built with ❤️ for the SurrealDB community

About

A type-safe TypeScript ORM for SurrealDB with full type inference, a fluent query builder, and native support for graph relationships

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors