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,
// ctx.user.id is the authentication record's id — compare against
// ctx.user.user.id (the populated user record) to check "own profile"
update: (ctx: PermissionContext) => ctx.user?.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, QueryContext } from "@datrix/core"
import type {
QueryInsertObject,
QueryUpdateObject,
QueryDeleteObject,
QuerySelectObject,
} from "@datrix/core"
type Post = DatrixEntry & { title: string; status: "draft" | "published" }
const postSchema = defineSchema({
name: "post",
fields: {
title: { type: "string", required: true },
status: { type: "enum", values: ["draft", "published"], required: true },
},
hooks: {
// --- write hooks ---
// Runs before INSERT — can modify the query (e.g. inject default field values)
beforeCreate: (
query: QueryInsertObject<Post>,
ctx: QueryContext,
): QueryInsertObject<Post> => {
return {
...query,
data: query.data.map((d) => ({ ...d, status: d.status ?? "draft" })),
}
},
// Runs after INSERT — receives all inserted records
afterCreate: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => {
return records
},
// Runs before UPDATE — must return the (possibly modified) query
beforeUpdate: (
query: QueryUpdateObject<Post>,
ctx: QueryContext,
): QueryUpdateObject<Post> => {
return query
},
afterUpdate: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => records,
// Runs before DELETE — receives the full delete query, must return it
beforeDelete: (
query: QueryDeleteObject<Post>,
ctx: QueryContext,
): QueryDeleteObject<Post> => {
return query
},
// Runs after DELETE — receives the deleted records, return value is ignored
afterDelete: (
records: readonly Post[],
ctx: QueryContext,
): void => {},
// --- read hooks ---
// Runs before SELECT — can modify the query object
beforeFind: (
query: QuerySelectObject<Post>,
ctx: QueryContext,
): QuerySelectObject<Post> => {
return {
...query,
where: { ...query.where, status: "published" },
}
},
// Runs after SELECT — receives all matching rows, must return them
afterFind: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => {
return records
},
},
})
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
},
}));
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" });