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, andpopulateexist on the schema - Operators are valid (
$eq,$gt,$in, etc.) - Relation fields are not used in
select(usepopulateinstead) populatetargets are actual relation fields, not scalar fields- Nesting depth limits are enforced (max 10 for
where, max 5 for data) deleterequires awhereclause
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:
| Hook | Can modify | Error behavior |
|---|---|---|
onBeforeQuery | query object | error re-thrown, query aborted |
onAfterQuery | result | error logged, result returned as-is |
onCreateQueryContext | context object | error 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.