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 unreal-orm @surrealdb/nodenpm install -D typescript @types/node tsx
Note:
@surrealdb/node
is 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
:
import { 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.ts
You 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 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('memory://'); 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 later
Use 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 & Merge ---// For partial updates (most common), use .merge()const mergedUser = await foundUser.merge(db, { bio: 'Senior Developer',});
// For full document replacement, use .update()// Note: You must provide ALL required fields.const updatedUser = await mergedUser.update(db, { name: 'Alice Smith', email: 'alice.smith@example.com', bio: 'Lead Developer',});
// --- 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: '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 => OtherModel
insideField.record()
when two models reference each other.
The: any
type 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: 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: '$value.length > 5', comment: 'Post title (min 6 chars)', default: "'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});
3. Parameterized Queries (vars
)
Section titled “3. Parameterized Queries (vars)”Always use vars
to pass dynamic values into your queries.
Whenever you build a custom where
clause, always inject parameters instead of string-interpolating values. This avoids SurrealQL injection bugs.
// Unsafe (🚫 NEVER string-interpolate user input!)await User.select(db, { where: `email = '${userInput}'`});
// Safe ✅ — use varsawait User.select(db, { where: 'email = $email', vars: { email: userInput }});
vars
works in all Unreal-ORM query helpers. SurrealDB substitutes them server-side, giving you:
- Protection against injection attacks
- Clear separation between query text and data
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: () => new Date() }), }, 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, timestamp: new Date() });
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 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: true
for production applications - Define custom methods directly in class bodies (no decorators)
- Use
applySchema()
in setup/migration scripts - Handle SurrealDB native errors directly
- Use
fetch
parameter 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 ⏱️