myniqx.dev
DatrixTypeScriptORMDatabaseCode Review

Inside Datrix: A Line-by-Line Read of a TypeScript ORM's Core Layer

myniqx_admin
myniqx_admin
LEAD DEVELOPER
Claude
Claude
AI ASSISTANT
published
July 18, 2026
exfiltrate

There are two ways to evaluate an ORM: read the README, or read the source code. This post was written after doing the latter — I went through roughly 13,000 lines of Datrix's core package (query builder, executor, validator, schema registry, and migration system) line by line. Below is what I found: the architectural ideas, the parts that genuinely work well, and the corners that haven't matured yet.

02 //What is Datrix?

Datrix is a TypeScript-first database management framework. It positions itself as "a plugin that bolts onto your existing project": you define your schemas with defineSchema(), wire up an adapter (Postgres, MySQL...) with defineConfig(), and in return you get type-safe CRUD, automatic relation management, migrations, and — via the @datrix/api package — automatic REST endpoints.

const post = defineSchema({
  name: "Post",
  fields: {
    title: { type: "string", required: true, maxLength: 200 },
    author: { type: "relation", kind: "belongsTo", model: "User" },
    tags: { type: "relation", kind: "manyToMany", model: "Tag" },
  },
});

// id, createdAt, updatedAt are automatic. FK column (authorId) is automatic.
// post_tag junction table is automatic.
await datrix.create("Post", { title: "Hello", author: 5, tags: [1, 2, 3] });

It sits somewhere between Strapi's schema-driven approach and Prisma's in-code definitions; unlike either, though, the schema is a plain TypeScript object that lives at runtime — there's no codegen step.

03 //The architectural spine: a three-layer responsibility model

Datrix's clearest design decision is splitting validation responsibility across three layers with hard boundaries:

Query Builder  →  structural validation (does the field exist, is the operator valid, type coercion)
Executor       →  data validation (required, min/max, pattern, enum) + timestamps + relations
Adapter        →  ONLY SQL translation (parameterized queries, type conversion)

The critical rule: the adapter never validates data. The query builder doesn't produce per-adapter queries; it produces a standard QueryObject that the adapter reads. This makes writing an adapter substantially easier — by the time a where clause reaches the adapter, every value has already been coerced to its type and every field name has already been checked against the schema. If a string "5" lands in a number field, the builder has already turned it into 5; if a field called categoryId doesn't exist, the query blows up before the adapter ever sees it.

There's a practical payoff to this discipline too: every adapter reading the same QueryObject produces the same behavior. The classic ORM failure mode — "it worked on Postgres, but behaves differently on MySQL" — usually stems from validation leaking into adapters; Datrix closes that door architecturally.

04 //Standout capabilities

Relation management is genuinely automatic. belongsTo, hasOne, hasMany, manyToMany — all four are supported, with FK columns and junction tables generated by the registry. There are plenty of shortcuts for writing relations: author: 5, tags: [1, 2, 3], or Prisma-style { connect, disconnect, set, create, update, delete } operations. There's even nested create:

await datrix.create("Post", {
  title: "New",
  author: { create: { name: "John", company: { create: { name: "Acme" } } } },
});

The executor handles this with a "resolve-then-link" strategy: create/update/delete operations run exactly once first, the resulting IDs get added to the connect lists, and only then does each record get linked purely by ID. It's a design-level attempt to prevent the disaster of "updating N records ran the nested create N times and produced duplicates."

A bulk-first philosophy. Datrix has a stance that's rarely stated outright but is clearly felt in the code: the core refuses to fire N queries in a loop. In bulk inserts, relations are applied identically across all records ("these 5 products all belong to this category") — anyone who needs a different relation per record has to write their own loop. That's not a gap, it's a deliberate boundary: in a world where ORMs silently generate N+1 queries, a framework that says "I won't do this, you do it" is an honest framework. (My one gripe: this contract doesn't shout loudly enough in the docs yet.)

Populate flexibility. populate('*'), populate(['author.company']) (dot notation), nested { select, where, limit, orderBy } options — all normalized down to a single format before reaching the adapter. The Postgres adapter supports three strategies: JSON aggregation, LATERAL join, batched IN. Hidden fields (FK columns) are automatically stripped out of wildcards.

A surprisingly ambitious migration system. Most young ORMs stop their migrations at "diff it, generate DDL." Datrix goes a step further and does ambiguity detection: if a column was removed and a similar one added, it asks "is this a rename, or a drop+add?" and prevents data loss based on the answer. Even more interesting, it recognizes relation-type changes: convert a belongsTo to a manyToMany and it asks "should I move the existing FK values into the junction table?" — and actually injects a data-transfer step that does it. The runner operates in three phases (createTable → DML/alter inside a transaction → dropTable after commit), so a failed migration can be rolled back without losing data. The schema itself is stored as JSON in the database too, which reduces the diff operation to comparing two JSON blobs — sidestepping dialect-specific introspection quirks entirely.

Plugin system and hook chain. There are onBeforeQuery/onAfterQuery plugin hooks alongside schema-level beforeCreate/afterFind lifecycle hooks; plugins can chain-modify the query. There's also an escape hatch, datrix.raw, that bypasses hooks entirely — internal operations like migration history use this path.

A zero-dependency validator. Instead of Zod or Joi, it has its own field validator: min/max, pattern, enum, array constraints (minItems/unique), custom validators, depth-limited recursive array validation. The error model is solid too: with abortEarly off, all errors are collected in one pass, structured as field/code/expected-value.

05 //Weak spots and areas still maturing

The cost of reading the code closely is seeing the flaws just as closely. Let's be honest:

There are holes in the validation shield. The layer model works flawlessly for where and select, but orderBy, groupBy, and having currently sit outside that shield — field names reach the adapter without being validated against the schema. Likewise, where inside populate isn't normalized either. Since adapters escape identifiers, this isn't a security disaster, but it does violate the "the adapter never re-checks what core already caught" contract, and it's currently the most significant known gap.

Silence is the biggest enemy. The common pattern among the bugs I found during review was staying silent instead of throwing: an invalid ID can turn into NaN and slip into a query, an unsupported relation format can get silently swallowed, populate: { rel: false } can mistakenly behave like true. None of these are architectural flaws — they're all "a throw needs to go here" — but silent data corruption is far more dangerous in an ORM than a loud crash.

Self-referential relations aren't reliable yet. In a self-referencing hasMany like Category → parent Category, the FK record can get lost; in a self manyToMany (the friendship-table scenario), source and target FKs collide under the same name. If you're modeling a tree or a graph, Datrix will fight you today.

ID policy: numbers only. A deliberate decision — some target adapters don't support string PKs — but a dealbreaker for anyone working with UUID-based architectures. An auto-incrementing numeric id is mandatory and reserved on every schema.

A false-positive risk in migration diffing. The diff mechanism measures fields like pattern (RegExp) and default by reference comparison; since the old schema loaded as JSON from the DB can never be reference-equal to the in-memory definition, these fields can appear to have "changed" constantly. With auto-migration on, this turns into unnecessary ambiguity prompts and init deadlocks.

Test coverage hasn't reached everywhere. There are 86 test files and an 80% coverage threshold, but some features — like the plugin schema-extension path (extendSchemas) — went uncaught by tests despite being unable to work as written. Even with a high coverage percentage, some critical paths were never exercised at all.

All of these items are tracked in a detailed issue list within the project, and most fall into the "mechanical fix" category — closable with well-understood fixes, without touching the architecture.

06 //Who should use it, who should wait?

Where it makes sense today: in a TypeScript monorepo, with a schema that lives in code, for teams wanting to automate relation management and REST API generation, happy with numeric IDs, on new projects. Thanks to the layer model, swapping adapters later remains a realistic option.

Who should wait: anyone requiring UUID/string PKs, anyone modeling self-referential relations (trees, graphs, friendships), and anyone wanting to lean on auto-migration in production. All three are on the roadmap, but not there yet.

07 //Closing

The sentence that stuck with me after reading Datrix's code: the right spine, missing muscle. The three-layer responsibility model, the standard QueryObject contract, the resolve-then-link relation strategy, and the ambiguity-aware migration system — these are the things you can't bolt on later, that have to be built right from the start, and Datrix built them right from the start. The gaps are, for the most part, code paths that forgot to throw on edge cases; tedious work, but well-understood work.

That's exactly the combination you'd want in a young ORM: no architectural debt, some engineering debt. The first kind never gets paid off. The second kind does.


08 //0.2.0 Update Note

This post was originally written against version 0.1.x. Here's where each item from the "Weak spots and areas still maturing" section stands as of 0.2.0:

There are holes in the validation shield. The layer model works flawlessly for where and select, but orderBy, groupBy, and having currently sit outside that shield — field names reach the adapter without being validated against the schema. Likewise, where inside populate isn't normalized either.

Fixed. orderBy/groupBy fields are now validated against the schema, having goes through the same where validation/normalization pipeline, and populate-level where/orderBy are also normalized against the target schema. The shield now operates at the same discipline level as where/select.

Silence is the biggest enemy. [...] an invalid ID can turn into NaN and slip into a query, an unsupported relation format can get silently swallowed, populate: { rel: false } can mistakenly behave like true.

Fixed. An invalid ID now throws instead of falling back to NaN/0 (the number-only ID policy is now enforced consistently across all layers), unsupported relation formats now raise an error instead of being silently swallowed, and populate: { rel: false } now genuinely behaves like false.

Self-referential relations aren't reliable yet. In a self-referencing hasMany like Category → parent Category, the FK record can get lost; in a self manyToMany (the friendship-table scenario), source and target FKs collide under the same name.

Fixed. Self-referential hasOne/hasMany no longer loses its FK; self-manyToMany junction tables now get distinct names like source<Model>Id/target<Model>Id, and adapter populate paths read these names from the schema too (previously they recomputed them via string templates). Tree/graph/friendship models are now reliable.

ID policy: numbers only. A deliberate decision [...] but a dealbreaker for anyone working with UUID-based architectures.

Unchanged — this was a design decision, not a bug; still holds.

A false-positive risk in migration diffing. The diff mechanism measures fields like pattern (RegExp) and default by reference comparison [...] these fields can appear to have "changed" constantly.

Fixed. Comparison is now value-based (pattern is converted to a string, default/items are structurally normalized before comparing); the old schema returned as JSON from the DB no longer gets flagged as "changed" against the in-memory definition unnecessarily.

Test coverage hasn't reached everywhere. [...] some features — like the plugin schema-extension path (extendSchemas) — went uncaught by tests despite being unable to work as written.

Fixed. extendSchemas now works (it uses an internal replace() path that skips the duplicate/reserved checks instead of register) and is covered by regression tests; more broadly, regression tests were added for most of the areas touched in this round.

While we're at it, an updated figure on the "test coverage" item: across the monorepo there are now a total of 89 test files, containing 708 describe blocks and 1718 it/test cases.

</> crafted by burak okur · myniqx.dev · 2026