How It Works

Every operation in Datrix passes through three layers in sequence:

QueryBuilder  →  Executor  →  Adapter

Understanding this pipeline makes it easier to know where errors come from, what gets validated and when, and how plugins interact with queries.


The Pipeline

1. QueryBuilder

The QueryBuilder is the first layer. It constructs a QueryObject — a plain, serializable description of the query — and validates its structure before any database call is made.

Validations at this stage:

  • Schema exists in the registry
  • All fields referenced in where, select, and populate exist on the schema
  • Operators are valid ($eq, $gt, $in, etc.)
  • Relation fields are not used in select (use populate instead)
  • populate targets are actual relation fields, not scalar fields
  • Nesting depth limits are enforced (max 10 for where, max 5 for data)
  • delete requires a where clause

If any of these checks fail, an error is thrown immediately — before touching the database. The error message includes the exact field name, the schema, and a suggestion.

// This throws at QueryBuilder stage — "nonexistent" is not a field on "user"
await datrix.findMany("user", {
  where: { nonexistent: "value" },
});
// DatrixQueryBuilderError: Field 'nonexistent' does not exist on schema 'user'
// Suggestion: Use one of: id, name, email, age, ...

2. Executor

The Executor receives the validated QueryObject and handles everything between the builder and the adapter.

For SELECT and COUNT, execution is straightforward:

onBeforeQuery hooks → adapter.executeQuery → onAfterQuery hooks

For INSERT, the flow is:

onBeforeQuery hooks
  → validate data (type, required, min/max/pattern/enum)
  → inject timestamps (createdAt, updatedAt)
  → BEGIN transaction
    → INSERT rows
    → process relations (connect/disconnect/set)
  → COMMIT
  → SELECT inserted records (separate query)
→ onAfterQuery hooks

For UPDATE, same as INSERT but partial validation (all fields optional):

onBeforeQuery hooks
  → validate data (partial)
  → inject updatedAt
  → BEGIN transaction
    → UPDATE rows
    → process relations
  → COMMIT
  → SELECT updated records by their IDs
→ onAfterQuery hooks

Note: after UPDATE, the re-fetch uses the IDs returned by the UPDATE statement — not the original where clause. This ensures you always get the current state of the records you actually modified.

For DELETE:

onBeforeQuery hooks
  → BEGIN transaction
    → SELECT rows (if populate/select was requested)
    → DELETE rows
  → COMMIT
→ onAfterQuery hooks

The pre-fetch before DELETE is what allows delete to return the deleted record with populated relations.

3. Adapter

The adapter receives the final QueryObject and translates it to database-specific SQL. It does no data validation — that was already done by the Executor.

What adapters do:

  • Translate QueryObject → SQL with parameterized values (prevents SQL injection)
  • Handle database-specific identifier quoting
  • Manage connection pooling and reconnection
  • Translate database errors into Datrix errors

datrix.crud vs datrix.raw

Every method is available in two forms:

// With plugin hooks
await datrix.create("user", data);
await datrix.crud.create("user", data);

// Without plugin hooks
await datrix.raw.create("user", data);

datrix.crud and datrix.findMany / datrix.create etc. run the full lifecycle — onBeforeQuery and onAfterQuery hooks from all registered plugins are called for every operation.

datrix.raw skips the dispatcher entirely. Hooks are not called. This is useful for:

  • Seeding initial data without triggering side effects
  • Internal plugin operations that should not recurse
  • Performance-critical bulk operations

Schema validation and timestamps still run in raw mode — only plugin hooks are skipped.


Plugin Hooks

Plugins can intercept every query at two points:

HookCan modifyError behavior
onBeforeQueryquery objecterror re-thrown, query aborted
onAfterQueryresulterror logged, result returned as-is
onCreateQueryContextcontext objecterror logged, context used as-is

onBeforeQuery errors are intentionally re-thrown — if a plugin rejects a query (e.g. a permission check fails), the operation stops.

onAfterQuery and onCreateQueryContext errors are swallowed and logged to console.error. A broken plugin cannot crash a read operation.

A plugin also receives a QueryContext on every hook call:

{
  action: "create",        // findOne | findMany | count | create | update | delete
  schema: SchemaDefinition,
  datrix: Datrix,            // full Datrix instance for internal queries
  metadata: {}             // arbitrary data plugins can attach
}

Error Quality

Datrix errors always include a suggestion field pointing to the likely fix:

DatrixCrudError: User record with id 999 not found
  code: RECORD_NOT_FOUND
  suggestion: Verify that the User with id 999 exists before attempting to update
DatrixCrudError: Cannot connect Post.tags: Tag records with ids [12, 34] not found
  code: RECORD_NOT_FOUND
  suggestion: Verify that all Tag IDs exist before connecting them to Post.tags
DatrixCrudError: Schema 'usr' not found
  code: SCHEMA_NOT_FOUND
  suggestion: Make sure the schema 'usr' is registered in your Datrix instance

Validation errors include the field name, what was expected, and what was received:

DatrixValidationError: Validation failed for 'user'
  fields:
    email: TYPE_MISMATCH — expected string, received number
    age:   MAX_VALUE — must be ≤ 120, received 200

Reserved fields (id, createdAt, updatedAt) cannot be written directly. Attempting to do so throws immediately with a suggestion to use datrix.raw if manual control is needed.