Schema
Schemas are plain TypeScript objects that describe your data models. Datrix uses them to validate data, generate migrations, and build queries.
Defining a schema
import type { SchemaDefinition } from '@datrix/core'
export const userSchema = {
name: 'user',
fields: {
name: { type: 'string', required: true },
email: { type: 'string', required: true, unique: true },
age: { type: 'number' },
},
} satisfies SchemaDefinition
The satisfies keyword gives full type checking while preserving the literal type —
Datrix needs this for type inference to work correctly.
Reserved fields
Every schema automatically gets these fields — you cannot define them manually:
| Field | Type | Description |
|---|---|---|
id | number | Auto-incremented primary key |
createdAt | date | Set on record creation |
updatedAt | date | Updated on every write |
Schema options
{
name: 'user', // Model name — used in queries (datrix.findMany('user'))
tableName: 'users', // Optional — defaults to pluralized name ('user' → 'users')
fields: { ... },
indexes: [ ... ], // Optional — database indexes
}
tableName
By default Datrix pluralizes the schema name for the table name (user → users,
category → categories). Override with tableName when needed:
{
name: 'userProfile',
tableName: 'user_profiles', // explicit table name
fields: { ... },
}
Field types
string
name: {
type: 'string',
required: true,
minLength: 2,
maxLength: 100,
unique: false,
pattern: /^[a-zA-Z\s]+$/,
default: '',
validator: (value) => value.trim().length > 0 || 'Name cannot be blank',
}
| Option | Type | Description |
|---|---|---|
required | boolean | Field must be present on create |
minLength | number | Minimum character count |
maxLength | number | Maximum character count |
unique | boolean | Enforces unique constraint in DB |
pattern | RegExp | Value must match regex |
default | string | Default value when not provided |
validator | (v) => true | string | Custom validation — return true or error message |
number
age: {
type: 'number',
required: false,
min: 0,
max: 120,
integer: true,
unique: false,
default: 0,
validator: (value) => value % 2 === 0 || 'Must be even',
}
| Option | Type | Description |
|---|---|---|
min | number | Minimum value |
max | number | Maximum value |
integer | boolean | Must be a whole number |
unique | boolean | Enforces unique constraint |
autoIncrement | boolean | Auto-increment (used internally for id) |
boolean
isActive: {
type: 'boolean',
required: false,
default: true,
}
date
publishedAt: {
type: 'date',
required: false,
min: new Date('2020-01-01'),
max: new Date(),
}
| Option | Type | Description |
|---|---|---|
min | Date | Earliest allowed date |
max | Date | Latest allowed date |
enum
role: {
type: 'enum',
values: ['admin', 'editor', 'user'],
required: true,
default: 'user',
}
values is the exhaustive list of allowed strings. Any other value fails validation.
array
tags: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 10,
unique: true,
}
| Option | Type | Description |
|---|---|---|
items | field definition | Type of each item in the array |
minItems | number | Minimum number of items |
maxItems | number | Maximum number of items |
unique | boolean | No duplicate values allowed |
json
metadata: {
type: 'json',
required: false,
}
Stores any valid JSON value. No deep validation is performed — useful for flexible or dynamic data structures.
relation
See Relations below.
Relations
Relations describe how schemas connect to each other. Datrix automatically manages foreign keys and junction tables — you never define them manually.
belongsTo
The current schema holds the foreign key. Use this when a record "belongs to" another.
// post belongs to user
author: {
type: 'relation',
kind: 'belongsTo',
model: 'user',
required: true,
// Datrix adds `authorId` foreign key column automatically
}
| Option | Type | Default | Description |
|---|---|---|---|
model | string | — | Target schema name |
required | boolean | false | Whether the relation is required |
foreignKey | string | ${fieldName}Id | Custom FK column name |
onDelete | 'cascade' | 'setNull' | 'restrict' | 'setNull' ('cascade' if required) | FK constraint behavior |
onUpdate | 'cascade' | 'restrict' | — | FK update constraint |
hasOne
The target schema holds the foreign key. One-to-one from the source side.
// user hasOne profile
profile: {
type: 'relation',
kind: 'hasOne',
model: 'profile',
// Datrix adds `userId` to the profile schema automatically
}
hasMany
One-to-many. The target schema holds the foreign key.
// user hasMany posts
posts: {
type: 'relation',
kind: 'hasMany',
model: 'post',
// Datrix adds `userId` to the post schema automatically
}
manyToMany
Many-to-many. Datrix creates a junction table automatically.
// post manyToMany tags
tags: {
type: 'relation',
kind: 'manyToMany',
model: 'tag',
// Datrix creates a `post_tag` junction table automatically
// Override with: through: 'custom_junction_table'
}
Indexes
indexes: [
{ fields: ['email'], unique: true },
{ fields: ['createdAt'] },
{ fields: ['firstName', 'lastName'], unique: false },
]
Lifecycle hooks
Lifecycle hooks let you intercept and modify data at each stage of a write or read operation. They are defined directly on a schema and fire only for that schema.
import { defineSchema } from "@datrix/core"
import type { QueryContext } from "@datrix/core"
import type {
QueryInsertObject,
QueryUpdateObject,
QueryDeleteObject,
QuerySelectObject,
} from "@datrix/core"
const postSchema = defineSchema({
name: "post",
fields: {
title: { type: "string", required: true },
status: { type: "enum", values: ["draft", "published"], required: true },
},
hooks: {
beforeCreate: (
query: QueryInsertObject<Post>,
ctx: QueryContext,
): QueryInsertObject<Post> => {
// Runs before INSERT — can modify the query (e.g. inject default field values)
return {
...query,
data: query.data.map((d) => ({ ...d, status: d.status ?? "draft" })),
}
},
afterCreate: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => {
// Runs after INSERT — receives all inserted records
return records
},
beforeUpdate: (
query: QueryUpdateObject<Post>,
ctx: QueryContext,
): QueryUpdateObject<Post> => {
// Runs before UPDATE — must return the (possibly modified) query
return query
},
afterUpdate: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => records,
beforeDelete: (
query: QueryDeleteObject<Post>,
ctx: QueryContext,
): QueryDeleteObject<Post> => {
// Runs before DELETE — receives the full delete query, must return it
return query
},
afterDelete: (
records: readonly Post[],
ctx: QueryContext,
): void => {
// Runs after DELETE — receives the deleted records, return value is ignored
},
beforeFind: (
query: QuerySelectObject<Post>,
ctx: QueryContext,
): QuerySelectObject<Post> => {
// Runs before SELECT — can modify the query object
return query
},
afterFind: (
records: readonly Post[],
ctx: QueryContext,
): readonly Post[] => {
// Runs after SELECT — receives all matching rows, must return them
return records
},
},
})
Error behaviour
| Hook type | If hook throws | If hook returns undefined |
|---|---|---|
before* | Error propagates — operation is aborted | HOOK_INVALID_RETURN error thrown |
after* | Warning logged — operation result is still returned | Warning logged |
After-hook errors are non-fatal by design: the database write already completed. The error is logged with console.warn and the result is returned as-is.
Sharing data between before and after
ctx.metadata is a plain mutable object shared between the before and after hook for the same operation:
hooks: {
beforeUpdate: (query, ctx) => {
ctx.metadata.previousStatus = query.data.status
return query
},
afterUpdate: (records, ctx) => {
for (const record of records) {
if (ctx.metadata.previousStatus !== record.status) {
// status changed — trigger a side effect
}
}
return records
},
}
Execution order
Schema hooks run after all plugin query hooks:
How Datrix initializes schemas
When you call getDatrix() for the first time, Datrix runs the following sequence:
_datrix table for internal bookkeeping
All schemas from your config
Plugins can contribute their own schemas (e.g. soft-delete adds deletedAt)
Plugins can add/remove/modify fields on existing schemas
_datrix_migration table for migration history
Processes all relations (adds FK columns, creates junction tables), sorts schemas by dependency order
Plugins receive the final schema state
Plugins can react to the finalized schema registry
This order means:
- Plugins can extend user schemas before relations are processed
- Foreign keys and junction tables are always generated from the final schema state
- You never need to define FK columns manually — Datrix handles them
Schema registry
After initialization, you can inspect registered schemas at runtime:
const datrix = await getDatrix()
// Get a single schema
const schema = datrix.getSchema('user')
// Get all schemas
const schemas = datrix.getAllSchemas()
// Check if schema exists
const exists = datrix.hasSchema('user')