Skip to content
🚀 This documentation is for unreal-orm 1.0.0 alpha which requires SurrealDB 2.0 SDK. For use with version 1.x, see here.

UnrealORM Tutorial

We will be building a small blog API with users, posts, and comments using UnrealORM and SurrealDB.

This tutorial focuses on UnrealORM features and how to use the ORM effectively. We expect it to take around 20-25 minutes if you follow along.


UnrealORM is designed for SurrealDB and can run on Node.js or Bun. For this tutorial, we’ll use Bun with the in-memory SurrealDB database.

Terminal window
bun init -y
bun add surrealdb@latest unreal-orm@latest @surrealdb/node@latest
bun add -D typescript @types/bun

Note: @surrealdb/node is required for running SurrealDB embedded locally in a Node.js or Bun environment. This is great for prototyping, development, and testing.

Create src/index.ts:

import { Surreal } from 'surrealdb';
import { createNodeEngines } from '@surrealdb/node';
const db = new Surreal({ engines: createNodeEngines() });
async function main() {
await db.connect('mem://', {
namespace: 'blog',
database: 'tutorial',
});
console.log('Connected to SurrealDB!');
}
main().catch(console.error);

Run it to verify setup:

Terminal window
bun run src/index.ts

You should see “Connected to SurrealDB!” output.


Let’s create a User model with basic fields:

src/index.ts
import { Surreal } from 'surrealdb';
import { createNodeEngines } from '@surrealdb/node';
import { Table, Field, Unreal } from 'unreal-orm';
import { surql } from 'surrealdb';
class User extends Table.normal({
name: 'user',
schemafull: true,
fields: {
name: Field.string(),
email: Field.string({ assert: surql`string::is::email($value)` }),
bio: Field.option(Field.string()),
},
}) {}
async function main() {
const db = new Surreal({ engines: createNodeEngines() });
await db.connect('mem://', {
namespace: 'blog',
database: 'tutorial',
});
// Apply schema to database
await Unreal.applySchema(db, [User]);
// Create a user
const user = await User.create(db, {
name: 'Alice',
email: 'alice@example.com',
bio: 'Full-stack developer'
});
console.log('Created user:', user);
}
main().catch(console.error);

Run this and you should see your first user created with type safety!


UnrealORM automatically generates SurrealQL DDL statements for your models:

Note on Required Fields: In UnrealORM, fields are required by default. This means SurrealDB will reject any write operation where a required field is missing. To make a field optional, you must wrap it in Field.option().

-- The ORM generates and applies:
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD name ON TABLE user TYPE string;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD bio ON TABLE user TYPE option<string>;

The Unreal.applySchema function applies all these definitions to SurrealDB.

Sometimes you want to generate DDL without executing it (e.g. for migration scripts or CI schema-drift checks):

import { Unreal } from 'unreal-orm';
// Pass one or more model classes – returns a single SurrealQL script
const ddl = Unreal.generateFullSchemaQl([User, Post]);
console.log(ddl);
// db.query(ddl) // you can run it manually later

Use this in pipelines to compare the generated DDL against committed files and fail the build if they differ.


Now let’s explore the core features of UnrealORM: performing CRUD operations, adding custom business logic to models, and defining database indexes.

UnrealORM provides a complete, type-safe API for creating, reading, updating, and deleting records.

// --- 1. Create ---
const user = await User.create(db, {
name: 'Alice',
email: 'alice@example.com',
bio: 'Developer',
});
// --- 2. Read ---
// Find a single record by its ID
const foundUser = await User.select(db, { from: user.id, only: true });
// --- 3. Update ---
// Partial update (most common): merge specific fields
const mergedUser = await foundUser.update(db, {
data: { bio: 'Senior Developer' },
mode: 'merge',
});
// Full document replacement: content mode (must provide ALL required fields)
const updatedUser = await mergedUser.update(db, {
data: {
name: 'Alice Smith',
email: 'alice.smith@example.com',
bio: 'Lead Developer',
},
mode: 'content',
});
// --- 4. Delete ---
// You can delete a record using the instance method
await updatedUser.delete(db);
// Or delete by ID using the static method
// await User.delete(db, updatedUser.id);

You can add custom business logic directly to your model classes. Instance methods have access to record data via this, while static methods are useful for creating custom queries.

import { surql } from 'surrealdb';
class User extends Table.normal({
name: 'user',
schemafull: true,
fields: {
name: Field.string(),
email: Field.string({ assert: surql`string::is::email($value)` }),
bio: Field.option(Field.string()),
},
}) {
// Instance Method
getDisplayName() {
return `${this.name} <${this.email}>`;
}
// Static Method
static async findByEmail(db: Surreal, email: string) {
const users = await this.select(db, {
where: surql`email = $email`,
vars: { email },
});
return users[0]; // Return the first match or undefined
}
}
// --- Using Custom Methods ---
const bob = await User.create(db, { name: 'Bob', email: 'bob@example.com' });
// Call the static finder
const foundBob = await User.findByEmail(db, 'bob@example.com');
// Call the instance method
console.log(foundBob?.getDisplayName()); // Outputs: "Bob <bob@example.com>"

Indexes are crucial for query performance and enforcing data integrity. Define them with Index.define() and pass them to Unreal.applySchema alongside your models.

import { Index } from 'unreal-orm';
// Define a unique index on the email field
const UserEmailIndex = Index.define(() => User, {
name: 'user_email_unique',
fields: ['email'],
unique: true, // Enforce uniqueness
});
// Apply schema for models AND indexes
await Unreal.applySchema(db, [User, Post, UserEmailIndex]);
// Now, SurrealDB will throw an error if you try to create
// two users with the same email address.

Define relationships between models using Field.record() and fetch related data with the fetch option.

Circular Dependencies? Use a thunk () => OtherModel inside Field.record() when two models reference each other.

class Post extends Table.normal({
name: 'post',
schemafull: true,
fields: {
title: Field.string(),
content: Field.string(),
author: Field.record(() => User),
tags: Field.option(Field.array(Field.string())),
published: Field.bool({ default: surql`false` }),
},
}) {}
async function testRelations() {
const author = await User.create(db, { name: 'Charlie', email: 'charlie@example.com' });
const post = await Post.create(db, {
title: 'Hydration is Awesome',
content: '...',
author: author.id,
});
// Fetch the post and its author
const result = await Post.select(db, {
from: post.id,
only: true,
fetch: ['author'],
});
// result.author is now a fully-typed User instance!
console.log(`Post by ${result?.author.getDisplayName()}`);
}

UnrealORM provides both TypeScript and SurrealDB-level validation:

async function testValidation() {
try {
// This will fail - missing required field 'name'
await User.create(db, {
email: 'incomplete@example.com'
});
} catch (err) {
console.log('Validation error:', err.message);
}
try {
// This will fail - duplicate email (unique index)
await User.create(db, {
name: 'Another Bob',
email: 'bob@example.com' // Already exists
});
} catch (err) {
console.log('Constraint error:', err.message);
}
}

SurrealDB native errors are passed through directly - no ORM-specific error wrapping.


For many-to-many relationships, use Table.relation. The dedicated .relate() method is the preferred way to create edges:

class Comment extends Table.normal({
name: 'comment',
schemafull: true,
fields: {
content: Field.string(),
author: Field.record(() => User),
post: Field.record(() => Post),
},
}) {}
// Edge table for likes
class Liked extends Table.relation({
name: 'liked',
schemafull: true,
fields: {
in: Field.record(() => User),
out: Field.record(() => Post),
timestamp: Field.datetime({ default: surql`time::now()` }),
},
}) {}
async function testEdges() {
await Unreal.applySchema(db, [User, Post, Comment, Liked]);
// Create like relationship using .relate()
const like = await Liked.relate(db, {
from: user.id,
to: post.id,
});
console.log('User liked post at:', like.timestamp);
}

Here’s the full working blog API:

src/blog-api.ts
import { Surreal } from 'surrealdb';
import { createNodeEngines } from '@surrealdb/node';
import { Table, Field, Index, Unreal } from 'unreal-orm';
import { surql } from 'surrealdb';
// Models
class User extends Table.normal({
name: 'user',
schemafull: true,
fields: {
name: Field.string(),
email: Field.string({ assert: surql`string::is::email($value)` }),
bio: Field.option(Field.string()),
},
}) {
getDisplayName() {
return `${this.name} <${this.email}>`;
}
}
class Post extends Table.normal({
name: 'post',
schemafull: true,
fields: {
title: Field.string(),
content: Field.string(),
author: Field.record(() => User),
published: Field.bool({ default: surql`false` }),
},
}) {}
// Define a unique index
const UserEmailIndex = Index.define(() => User, {
name: 'user_email_unique',
fields: ['email'],
unique: true,
});
async function main() {
const db = new Surreal({ engines: createNodeEngines() });
await db.connect('mem://', {
namespace: 'blog',
database: 'tutorial',
});
// Apply schema for models and indexes
await Unreal.applySchema(db, [User, Post, UserEmailIndex]);
// Create and test
const author = await User.create(db, {
name: 'Tutorial Author',
email: 'author@example.com',
bio: 'Learning UnrealORM'
});
const post = await Post.create(db, {
title: 'Getting Started with UnrealORM',
content: 'This ORM is amazing for SurrealDB!',
author: author.id,
published: true
});
// Query with hydration
const result = await Post.select(db, {
from: post.id,
only: true,
fetch: ['author']
});
console.log(`Post "${result?.title}" by ${result?.author.getDisplayName()}`);
await db.close();
}
main().catch(console.error);

Run this and see your complete blog API in action!


vs SurrealDB JS SDK:

Feature UnrealORM SurrealDB SDK
Type Safety Full TypeScript Manual typing
Schema Generation Automatic DDL Manual SQL
Relations & Hydration Typed hydration Manual joins
Validation TS + DB level Manual checks
Custom Methods Class methods Separate functions

Best Practices:

  • Use schemafull: true for production applications
  • Define custom methods directly in class bodies (no decorators)
  • Use Unreal.applySchema() in setup/migration scripts
  • Handle SurrealDB native errors directly
  • Use fetch parameter for efficient relation hydration
  • Pass namespace, database, and authentication in a single .connect() call

Check out the API Reference for complete documentation!