Setup
Two functions are the entry point to every Datrix project: defineSchema and defineConfig.
$pnpm add @datrix/core
defineSchema
A helper that lets TypeScript check your schema object against SchemaDefinition. At runtime it does nothing — it returns the object as-is. Using it is optional; passing a plain object directly is functionally identical.
import { defineSchema } from "@datrix/core";
const postSchema = defineSchema({
name: "post",
fields: {
title: { type: "string", required: true },
},
});
Schema options
defineSchema({
name: "post", // model name — used in queries, auto-pluralized for table name
tableName: "blog_posts", // override table name (optional)
fields: { ... }, // field definitions — see below
indexes: [ // optional indexes
{ fields: ["slug"], unique: true },
{ fields: ["authorId"] },
],
hooks: { ... }, // lifecycle hooks — see below
permission: { ... }, // schema-level permissions — see below
})
Field types
Every field must have a type. Additional options depend on the type.
string
title: {
type: "string",
required: true,
minLength: 3,
maxLength: 200,
pattern: /^[a-z0-9-]+$/, // regex
unique: true,
default: "",
validator: (v) => v.trim().length > 0 || "Cannot be blank",
}
number
price: {
type: "number",
required: true,
min: 0,
max: 99999,
integer: false, // true → validates and stores as integer
unique: false,
default: 0,
}
boolean
isPublished: {
type: "boolean",
default: false,
}
date
publishedAt: {
type: "date",
required: false,
min: new Date("2020-01-01"),
}
json
meta: {
type: "json",
required: false,
// stored as JSONB (PostgreSQL) or JSON (MySQL/MongoDB)
}
enum
status: {
type: "enum",
values: ["draft", "published", "archived"] as const,
default: "draft",
}
array
tags: {
type: "array",
items: { type: "string" }, // any field type works as items
minItems: 0,
maxItems: 20,
unique: true, // all items must be distinct
}
file
avatar: {
type: "file",
allowedTypes: ["image/jpeg", "image/png", "image/webp"],
maxSize: 5 * 1024 * 1024, // 5 MB in bytes
multiple: false,
}
relation
// belongsTo — FK lives on this table (N:1)
author: {
type: "relation",
kind: "belongsTo",
model: "user",
onDelete: "restrict", // "cascade" | "setNull" | "restrict"
}
// hasMany — FK lives on the target table (1:N)
comments: {
type: "relation",
kind: "hasMany",
model: "comment",
}
// hasOne — FK lives on the target table (1:1)
profile: {
type: "relation",
kind: "hasOne",
model: "profile",
}
// manyToMany — junction table auto-created
tags: {
type: "relation",
kind: "manyToMany",
model: "tag",
through: "post_tags", // optional — defaults to auto-generated name
}
Full example: two schemas with all types
import { defineSchema } from "@datrix/core";
import type { PermissionContext } from "@datrix/core";
type Roles = "admin" | "editor" | "user";
// user.schema.ts
export const userSchema = defineSchema({
name: "user",
fields: {
email: {
type: "string",
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
unique: true,
},
name: {
type: "string",
required: true,
minLength: 2,
maxLength: 100,
},
role: {
type: "enum",
values: ["admin", "editor", "user"] as const,
default: "user",
},
age: {
type: "number",
min: 0,
max: 150,
integer: true,
},
isActive: {
type: "boolean",
default: true,
},
meta: {
type: "json",
},
posts: {
type: "relation",
kind: "hasMany",
model: "post",
},
},
indexes: [{ fields: ["email"], unique: true }, { fields: ["role"] }],
permission: {
create: ["admin"] as readonly Roles[],
read: true,
update: (ctx: PermissionContext) => ctx.user?.id === ctx.id,
delete: ["admin"] as readonly Roles[],
},
});
// post.schema.ts
export const postSchema = defineSchema({
name: "post",
fields: {
title: {
type: "string",
required: true,
minLength: 3,
maxLength: 200,
},
slug: {
type: "string",
required: true,
pattern: /^[a-z0-9-]+$/,
unique: true,
},
content: {
type: "string",
required: true,
},
status: {
type: "enum",
values: ["draft", "published", "archived"] as const,
default: "draft",
},
publishedAt: {
type: "date",
},
viewCount: {
type: "number",
default: 0,
min: 0,
integer: true,
},
cover: {
type: "file",
allowedTypes: ["image/jpeg", "image/png", "image/webp"],
maxSize: 5 * 1024 * 1024,
},
extraData: {
type: "json",
},
keywords: {
type: "array",
items: { type: "string" },
maxItems: 20,
unique: true,
},
// belongsTo — stores authorId FK on posts table
author: {
type: "relation",
kind: "belongsTo",
model: "user",
onDelete: "restrict",
},
// hasOne — profile table holds postId FK
seoMeta: {
type: "relation",
kind: "hasOne",
model: "seoMeta",
},
// manyToMany — junction table post_tags auto-created
tags: {
type: "relation",
kind: "manyToMany",
model: "tag",
},
},
indexes: [
{ fields: ["slug"], unique: true },
{ fields: ["authorId"] },
{ fields: ["status"] },
],
permission: {
create: ["admin", "editor"] as readonly Roles[],
read: true,
update: ["admin", "editor"] as readonly Roles[],
delete: ["admin"] as readonly Roles[],
},
});
Lifecycle hooks
Hooks run on every non-raw query for the schema. They fire after plugin hooks, and only when the operation goes through the dispatcher (i.e. not on datrix.raw.* calls).
Every before hook must return the (optionally modified) value — the return value replaces the current data or query. After hooks must return the (optionally modified) result.
ctx.metadata is a plain object shared between the before and after hook of the same operation. Use it to pass data across the two phases.
import { defineSchema } from "@datrix/core"
import type { DatrixEntry } from "@datrix/core"
type Post = DatrixEntry & { title: string; slug: string; status: string }
export const postSchema = defineSchema({
name: "post",
fields: { ... },
hooks: {
// --- write hooks ---
// Runs before INSERT. Returns modified data.
beforeCreate: async (data, ctx) => {
ctx.metadata.createdAt = Date.now()
return { ...data, slug: data.title?.toLowerCase().replace(/ /g, "-") }
},
// Runs after INSERT. Receives the full saved record.
afterCreate: async (record, ctx) => {
console.log("created in", Date.now() - (ctx.metadata.createdAt as number), "ms")
return record
},
// Runs before UPDATE. Returns modified data.
beforeUpdate: async (data, ctx) => {
return data // return as-is or modify
},
// Runs after UPDATE. Receives the full updated record.
afterUpdate: async (record, ctx) => {
return record
},
// Runs before DELETE. Receives the id, must return the id to delete.
beforeDelete: async (id, ctx) => {
ctx.metadata.deletedId = id
return id // return same id, or redirect to a different one
},
// Runs after DELETE. Receives the deleted id.
afterDelete: async (id, ctx) => {
console.log("deleted id", id)
},
// --- read hooks ---
// Runs before SELECT. Receives the full QuerySelectObject, must return it.
// Use this to inject additional WHERE conditions.
beforeFind: async (query, ctx) => {
return {
...query,
where: { ...query.where, status: "published" },
}
},
// Runs after SELECT. Receives the result array, must return it.
afterFind: async (results, ctx) => {
return results.filter((r) => (r as Post).status !== "archived")
},
},
})
Permissions
Schema permissions control who can perform CRUD operations.
permission: {
create: ["admin", "editor"], // role array
read: true, // always allowed
update: false, // always denied
delete: (ctx) => ctx.user !== undefined, // function — return true/false
}
PermissionValue can be:
| Value | Meaning |
|---|---|
true | Always allowed (public) |
false | Always denied |
string[] | Allowed if ctx.user.role is in the array |
(ctx) => boolean | Custom async or sync function |
Mixed array [role, fn, ...] | Allowed if any item passes |
Field-level permissions strip the field from responses (read) or return 403 (write):
email: {
type: "string",
permission: {
read: ["admin", "editor"], // others get the field stripped from response
write: ["admin"], // others get 403 on create/update
},
}
defineConfig
Creates the Datrix instance factory. Call the returned function to get the initialized Datrix instance. The factory runs only once — subsequent calls return the cached instance.
import { defineConfig } from "@datrix/core";
import { PostgresAdapter } from "@datrix/adapter-postgres";
import { userSchema, postSchema } from "./schemas";
const getDatrix = defineConfig(() => ({
// Required: database adapter
adapter: new PostgresAdapter({
host: "localhost",
port: 5432,
database: "mydb",
user: "postgres",
password: process.env.DB_PASSWORD,
}),
// Required: schema list
schemas: [userSchema, postSchema],
// Optional: plugin list
plugins: [],
// Optional: migration settings
migration: {
auto: false, // auto-run migrations on startup (default: false)
directory: "./migrations", // where migration files are stored (default: ./migrations)
modelName: "_datrix_migrations", // migration history table name
},
// Optional: development settings
dev: {
logging: false, // log every query to console
validateQueries: false, // extra query validation before execution
prettyErrors: false, // pretty-print errors with stack traces
},
}));
export default getDatrix;
Using the instance
import getDatrix from "./datrix.config";
// Get the initialized instance
const datrix = await getDatrix();
// findMany — returns array
const posts = await datrix.findMany("post", {
where: { status: "published" },
orderBy: [{ field: "publishedAt", direction: "desc" }],
limit: 10,
offset: 0,
populate: { author: { select: ["name", "email"] } },
});
// findOne — returns record or null
const post = await datrix.findOne("post", { slug: "hello-world" });
// findById — returns record or null
const user = await datrix.findById("user", 1);
// count
const total = await datrix.count("post", { status: "published" });
// create
const newPost = await datrix.create("post", {
title: "Hello World",
slug: "hello-world",
content: "...",
status: "draft",
author: 1, // connect by ID
tags: [1, 2, 3],
});
// update
const updated = await datrix.update("post", newPost.id, {
status: "published",
publishedAt: new Date(),
});
// updateMany
const updatedPosts = await datrix.updateMany(
"post",
{ status: "draft" },
{ status: "archived" },
);
// delete
const deleted = await datrix.delete("post", newPost.id);
// deleteMany
const deletedPosts = await datrix.deleteMany("post", { status: "archived" });