Quickstart
By the end of this page you will have a SurrealDB schema written in TypeScript, a migration generated from it, that migration applied to a running database, and app code that reads and writes rows as typed values. It takes about ten minutes.
This is a tutorial: follow every step in order and it works. The guides cover variations once you are comfortable.
Prerequisites
Install these first. SurrealQL differs across SurrealDB majors, so the versions matter.
- SurrealDB 3.x on your
PATH. Check withsurreal version. - Node 18+ (or Bun). Check with
node --version.
Create a project and install @schemic/core
@schemic/core ships both the library and the schemic CLI. surrealdb is the official SDK you will use to talk to the database from app code.
Scaffold the project
init writes a schemic.config.ts (where your database connection lives), a sample user schema, a seed file, and an empty migration snapshot. It never overwrites files that already exist.
Start a local SurrealDB
In a second terminal, start an in-memory database with root credentials that match the scaffolded config:
surreal start --user root --pass root memoryLeave it running. The default schemic.config.ts connects to ws://localhost:8000 with namespace app and database app, which is exactly what this command serves.
Read the generated schema
Open database/schema/tables/user.ts. This is the single source of truth; the DDL below is derived from it.
import { s, defineTable } from "@schemic/core";
import { surql } from "surrealdb";
export const User = defineTable("user", {
id: s.string(),
name: s.string(),
email: s.email(),
createdAt: s.datetime().$default(surql`time::now()`).$readonly(),
});It generates this DDL:
DEFINE TABLE user TYPE NORMAL SCHEMAFULL;
DEFINE FIELD name ON TABLE user TYPE string;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is_email($value);
DEFINE FIELD createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY;Three things to notice, each of which you will use constantly:
s.email()is a string that emits anASSERT string::is_email($value)constraint in the DDL. SurrealDB enforces it on write.s.datetime()is a codec field: aDatein your TypeScript code, adatetimeon the wire..$default(...)sets a database-sideDEFAULT— heretime::now(), written with thesurqltag — and.$readonly()marks the fieldREADONLY.
Generate your first migration
schemic gen diffs your schema against the recorded snapshot and writes a migration for the difference.
The migration file contains the DEFINE statements above, wrapped so it can run forward (migrate) or backward (rollback). You commit these files to version control.
Apply the migration
Confirm the state:
Your database now has the user table.
Evolve the schema
A schema change is an edit to your TypeScript. Add a post table next to user. Create database/schema/tables/post.ts:
import { s, defineTable } from "@schemic/core";
import { surql } from "surrealdb";
import { User } from "./user";
export const Post = defineTable("post", {
id: s.string(),
title: s.string().$assert(surql`string::len($value) > 0`),
body: s.string(),
author: User.record(),
publishedAt: s.datetime().optional(),
createdAt: s.datetime().$default(surql`time::now()`).$readonly(),
});User.record() is a typed record link — a record<user> field on the wire — built from the imported User definition, so the table name lives only in user.ts. Generate and apply a migration for the change:
npx schemic gen add_posts
npx schemic migrategen writes a second migration containing only the new post table — it diffs against the snapshot, so it never re-defines user.
Read and write rows as typed values
Your schema also gives you codecs and types for app code. encode turns app values into a wire payload for a write; decode turns a database row back into app values.
import { Surreal } from "surrealdb";
import { User } from "./database/schema/tables/user";
import type { App } from "@schemic/core";
const db = new Surreal();
await db.connect("ws://localhost:8000", {
namespace: "app",
database: "app",
auth: { username: "root", password: "root" },
});
// encode() builds the wire payload — createdAt has a DB default, so you omit it.
// db.query with CONTENT is the version-robust way to write; SurrealDB assigns the id.
const [[created]] = await db.query("CREATE user CONTENT $data", {
data: User.encode({ name: "Ada Lovelace", email: "ada@example.com" }),
});
// decode() validates the returned row and converts wire values to app values.
const ada: App<typeof User> = User.decode(created);
console.log(ada.createdAt instanceof Date); // true — a Date, not a wire datetimeUser.encode(...) is typed to the create shape (fields with a default are optional), and App<typeof User> is the decoded app type. The createdAt you read back is a JavaScript Date, decoded from the datetime SurrealDB stored.
What you built
- A SurrealDB schema authored entirely in TypeScript.
- Two migrations, generated by diffing that schema and committed to your repo.
- App code that writes and reads rows as fully-typed values, with the wire/app conversion handled for you.