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:
onBeforeQuery (plugins) → before* (schema) → [query runs] → onAfterQuery (plugins) → after* (schema)
How Datrix initializes schemas
When you call getDatrix() for the first time, Datrix runs the following sequence:
- Register internal metadata schema —
_datrixtable for internal bookkeeping - Register user schemas — all schemas from your config
- Register plugin schemas — plugins can contribute their own schemas (e.g. soft-delete adds
deletedAt) - Apply schema extensions — plugins can add/remove/modify fields on existing schemas
- Register migration schema —
_datrix_migrationtable for migration history - Finalize registry — processes all relations (adds FK columns, creates junction tables), sorts schemas by dependency order
- Initialize plugins — plugins receive the final schema state
- Dispatch schema load event — 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')