Unreal-ORM Tutorial
We will be building a small blog API with users, posts, and comments using Unreal-ORM and SurrealDB.
This tutorial focuses on Unreal-ORM features and how to use the ORM effectively. We expect it to take around 20-25 minutes if you follow along.
Unreal-ORM is designed for SurrealDB and can run on Node.js or Bun. For this tutorial, we’ll use Node.js with the in-memory SurrealDB database.
Install Dependencies
Section titled “Install Dependencies”npm init -ynpm install surrealdb@alpha unreal-orm@latest @surrealdb/node@alphanpm install -D typescript @types/node tsxNote:
@surrealdb/nodeis required for running SurrealDB embedded locally in a Node.js environment. This is great for prototyping, development, and testing.
Project Setup
Section titled “Project Setup”Create src/index.ts:
// src/index.tsimport { Surreal } from 'surrealdb';import { surrealdbNodeEngines } from '@surrealdb/node';import Table, { Field } from 'unreal-orm';
const db = new Surreal({ engines: surrealdbNodeEngines() });
async function main() { // Connect to in-memory database await db.connect('mem://'); await db.use({ namespace: 'blog', database: 'tutorial' });
console.log('Connected to SurrealDB!');}
main().catch(console.error);Run it to verify setup:
npx tsx 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:
// src/index.tsimport { Surreal } from 'surrealdb';import Table, { Field, applySchema } from 'unreal-orm';
class User extends Table.normal({ name: 'user', fields: { name: Field.string(), email: Field.string(), bio: Field.option(Field.string()), }, schemafull: true,}) {}
async function main() { const db = new Surreal(); await db.connect('mem://'); await db.use({ namespace: 'blog', database: 'tutorial' });
// Apply schema to database await 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);}Run this and you should see your first user created with type safety!
Schema Generation
Section titled “Schema Generation”Unreal-ORM automatically generates SurrealQL DDL statements for your models:
Note on Required Fields: In Unreal-ORM, 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;DEFINE FIELD bio ON TABLE user TYPE option<string>;The 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 { generateFullSchemaQl } from 'unreal-orm';
// Pass one or more model classes – returns a single SurrealQL scriptconst ddl = 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 Unreal-ORM: performing CRUD operations, adding custom business logic to models, and defining database indexes.
1. Core Operations (CRUD)
Section titled “1. Core Operations (CRUD)”Unreal-ORM 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.
class User extends Table.normal({ /* ...fields... */}) { // 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 applySchema alongside your models.
// 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 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
() : any => OtherModelinsideField.record()when two models reference each other.
The: anytype annotation suppresses TypeScript’s self-referencing complaint and disappears at runtime.
class Post extends Table.normal({ name: 'post', fields: { title: Field.string(), content: Field.string(), author: Field.record(() => User), tags: Field.option(Field.array(Field.string())), published: Field.bool({ default: surql`false` }), }, schemafull: true,}) {}
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()}`);}Advanced Topics
Section titled “Advanced Topics”1. Field Options
Section titled “1. Field Options”Customize fields with default, assert (validation), comment, etc.:
class Post extends Table.normal({ name: 'post', fields: { title: Field.string({ assert: surql`$value.length > 5`, comment: 'Post title (min 6 chars)', default: surql`'Untitled'`, }), content: Field.string(), }, schemafull: true,}) {}2. Custom SurrealDB types
Section titled “2. Custom SurrealDB types”const Duration = Field.custom<number>('duration');
class Task extends Table.normal({ name: 'task', fields: { title: Field.string(), est: Duration, // stored as SurrealDB duration type, typed as number in TS }, schemafull: true,}) {}
// Projection with type-safetyconst tasks = await Task.select(db, { fields: ['title'], // only 'title' will be in the result type});Validation & Error Handling
Section titled “Validation & Error Handling”Unreal-ORM 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:
class Comment extends Table.normal({ name: 'comment', fields: { content: Field.string(), author: Field.record(() => User), post: Field.record(() => Post), }, schemafull: true,}) {}
// Edge table for likesclass Liked extends Table.relation({ name: 'liked', fields: { in: Field.record(() => User), out: Field.record(() => Post), timestamp: Field.datetime({ default: surql`time::now()` }), }, schemafull: true,}) {}
async function testEdges() { await applySchema(db, [User, Post, Comment, Liked]);
// Create like relationship const like = await Liked.create(db, { in: user.id, out: post.id, });
console.log('User liked post at:', like.timestamp);}Complete Example
Section titled “Complete Example”Here’s the full working blog API:
// src/blog-api.tsimport { Surreal } from 'surrealdb';import Table, { Field, Index, applySchema } from 'unreal-orm';
// Modelsclass User extends Table.normal({ name: 'user', fields: { name: Field.string(), email: Field.string(), bio: Field.option(Field.string()), }, schemafull: true,}) { getDisplayName() { return `${this.name} <${this.email}>`; }}
class Post extends Table.normal({ name: 'post', fields: { title: Field.string(), content: Field.string(), author: Field.record(() => User), published: Field.bool({ default: false }), }, schemafull: true,}) {}
// Define a unique indexconst UserEmailIndex = Index.define(() => User, { name: 'user_email_unique', fields: ['email'], unique: true,});
async function main() { const db = new Surreal(); await db.connect('memory://'); await db.use({ namespace: 'blog', database: 'tutorial' });
// Apply schema for models and indexes await applySchema(db, [User, Post, UserEmailIndex]);
// Create and test const author = await User.create(db, { name: 'Tutorial Author', email: 'author@example.com', bio: 'Learning Unreal-ORM' });
const post = await Post.create(db, { title: 'Getting Started with Unreal-ORM', 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 | Unreal-ORM | 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
applySchema()in setup/migration scripts - Handle SurrealDB native errors directly
- Use
fetchparameter for efficient relation hydration
Next Steps
Section titled “Next Steps”- Advanced Relations: Explore more complex many-to-many patterns
- Permissions: Add SurrealDB table-level permissions
- Migrations: Version your schema changes
- Performance: Learn about indexing and query optimization
Check out the API Reference for complete documentation!
Total tutorial time: ~20 minutes ⏱️