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:

FieldTypeDescription
idnumberAuto-incremented primary key
createdAtdateSet on record creation
updatedAtdateUpdated 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 (userusers, categorycategories). 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',
}
OptionTypeDescription
requiredbooleanField must be present on create
minLengthnumberMinimum character count
maxLengthnumberMaximum character count
uniquebooleanEnforces unique constraint in DB
patternRegExpValue must match regex
defaultstringDefault value when not provided
validator(v) => true | stringCustom 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',
}
OptionTypeDescription
minnumberMinimum value
maxnumberMaximum value
integerbooleanMust be a whole number
uniquebooleanEnforces unique constraint
autoIncrementbooleanAuto-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(),
}
OptionTypeDescription
minDateEarliest allowed date
maxDateLatest 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,
}
OptionTypeDescription
itemsfield definitionType of each item in the array
minItemsnumberMinimum number of items
maxItemsnumberMaximum number of items
uniquebooleanNo 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
}
OptionTypeDefaultDescription
modelstringTarget schema name
requiredbooleanfalseWhether the relation is required
foreignKeystring${fieldName}IdCustom 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 typeIf hook throwsIf hook returns undefined
before*Error propagates — operation is abortedHOOK_INVALID_RETURN error thrown
after*Warning logged — operation result is still returnedWarning 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:

  1. Register internal metadata schema_datrix table for internal bookkeeping
  2. Register user schemas — all schemas from your config
  3. Register plugin schemas — plugins can contribute their own schemas (e.g. soft-delete adds deletedAt)
  4. Apply schema extensions — plugins can add/remove/modify fields on existing schemas
  5. Register migration schema_datrix_migration table for migration history
  6. Finalize registry — processes all relations (adds FK columns, creates junction tables), sorts schemas by dependency order
  7. Initialize plugins — plugins receive the final schema state
  8. 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')