Skip to content

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

License

Notifications You must be signed in to change notification settings

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

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)

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

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.

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 type
const userTable = table("user", {
  name: t.string(),
  age: t.number(),
});

type User = t.infer<typeof userTable>;
// 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 relationships

Surqlize provides type-safe graph traversal through the lookup system:

const user = table("user", { name: t.string() });
const post = table("post", { title: t.string() });
const authored = edge("user", "authored", "post", {});

const db = orm(new Surreal(), user, post, authored);

// TypeScript knows which edges connect to which tables
db.lookup.to;   // { user: ["authored"], authored: ["post"], post: [] }
db.lookup.from; // { user: [], authored: ["user"], post: ["authored"] }

// Use in queries for type-safe graph navigation
// (This feature is under active development)

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
  • Runtime validation - Validate data at runtime using schema definitions
  • Advanced graph traversal - Path finding, recursive queries, graph algorithms
  • 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! This project is in an experimental stage, so expect breaking changes. 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

No packages published

Contributors 2

  •  
  •