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.
Install Dependencies
Section titled “Install Dependencies”bun init -ybun add surrealdb@latest unreal-orm@latest @surrealdb/node@latestbun add -D typescript @types/bunNote:
@surrealdb/nodeis required for running SurrealDB embedded locally in a Node.js or Bun environment. This is great for prototyping, development, and testing.
Project Setup
Section titled “Project Setup”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:
bun run src/index.tsYou should see “Connected to SurrealDB!” output.
Define Your First Model
Section titled “Define Your First Model”Let’s create a User model with basic fields:
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!
Schema Generation
Section titled “Schema Generation”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.
Schema-only Mode
Section titled “Schema-only Mode”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 scriptconst ddl = Unreal.generateFullSchemaQl([User, Post]);console.log(ddl);// db.query(ddl) // you can run it manually laterUse this in pipelines to compare the generated DDL against committed files and fail the build if they differ.
Core Operations, Methods, & Indexes
Section titled “Core Operations, Methods, & Indexes”Now let’s explore the core features of UnrealORM: performing CRUD operations, adding custom business logic to models, and defining database indexes.
1. Core Operations (CRUD)
Section titled “1. Core Operations (CRUD)”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 IDconst foundUser = await User.select(db, { from: user.id, only: true });
// --- 3. Update ---// Partial update (most common): merge specific fieldsconst 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 methodawait updatedUser.delete(db);
// Or delete by ID using the static method// await User.delete(db, updatedUser.id);2. Custom Methods
Section titled “2. Custom Methods”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 finderconst foundBob = await User.findByEmail(db, 'bob@example.com');
// Call the instance methodconsole.log(foundBob?.getDisplayName()); // Outputs: "Bob <bob@example.com>"3. Defining Indexes
Section titled “3. Defining Indexes”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 fieldconst UserEmailIndex = Index.define(() => User, { name: 'user_email_unique', fields: ['email'], unique: true, // Enforce uniqueness});
// Apply schema for models AND indexesawait Unreal.applySchema(db, [User, Post, UserEmailIndex]);
// Now, SurrealDB will throw an error if you try to create// two users with the same email address.Relations & Hydration
Section titled “Relations & Hydration”Define relationships between models using Field.record() and fetch related data with the fetch option.
Circular Dependencies? Use a thunk
() => OtherModelinsideField.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()}`);}Validation & Error Handling
Section titled “Validation & Error Handling”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.
Edge Tables (Many-to-Many)
Section titled “Edge Tables (Many-to-Many)”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 likesclass 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);}Complete Example
Section titled “Complete Example”Here’s the full working blog API:
import { Surreal } from 'surrealdb';import { createNodeEngines } from '@surrealdb/node';import { Table, Field, Index, Unreal } from 'unreal-orm';import { surql } from 'surrealdb';
// Modelsclass 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 indexconst 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!
Key Takeaways
Section titled “Key Takeaways”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: truefor production applications - Define custom methods directly in class bodies (no decorators)
- Use
Unreal.applySchema()in setup/migration scripts - Handle SurrealDB native errors directly
- Use
fetchparameter for efficient relation hydration - Pass
namespace,database, andauthenticationin a single.connect()call
Next Steps
Section titled “Next Steps”- Advanced Relations: Explore more complex many-to-many patterns in Graph Relations
- Permissions: Add SurrealDB table-level permissions in Security & Permissions
- Migrations: Version your schema changes in Migrations & Schema Sync
- Cookbook: Quick recipes in the Cookbook
Check out the API Reference for complete documentation!