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:

ValueMeaning
trueAlways allowed (public)
falseAlways denied
string[]Allowed if ctx.user.role is in the array
(ctx) => booleanCustom 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" });