The hard part of shipping a database client in three languages is not writing three query builders, it is keeping them honest against each other over years of changes, because the moment one language’s surface drifts on a nullable column, or on how a cascade delete behaves, or on what a migration rollback actually does, you have three databases pretending to be one, and every bug report becomes an archaeology project about which SDK the user was touching when the assumption broke.
This is the problem MongrelDB-Kit exists to solve, and the solution is not three carefully maintained ORMs, it is one Rust core with three thin language surfaces that cannot disagree because they are all calling the same code.
The problem with three independent SDKs
The natural shape of a multi-language database client is three teams, or one team wearing three hats, each writing the query builder, the migration runner, and the constraint logic in their own language’s idiom, and this works beautifully for the first six months because everyone is building the same thing from the same spec and the spec is still small. Then the spec grows, someone adds a CHECK constraint in the Rust client, the TypeScript client ships two weeks later without it, and the Python client ships a month after that with a slightly different error message, and now you have three databases that agree on the happy path and disagree on every edge case, which is precisely where bugs live.
I have watched this happen with every multi-language database tool I have used in twenty years, and the failure mode is always the same: the schemas start identical, the implementations diverge, and nobody notices until a user files a bug that only reproduces in one language, at which point the fix is to reconcile three codebases instead of one.
The alternative is to not give yourself the opportunity to drift.
One core, three surfaces
MongrelDB-Kit is built on the three in-process native bindings we already ship for MongrelDB: Rust is the core itself, TypeScript (on Node) talks to it through a N-API native addon consumed from either JS or TS, and Python talks to it through PyO3, with no daemon and no serialization in the path, which means the Kit is not three implementations of a schema engine, it is one schema engine exposed through three language shims.
The schema is defined once, in a declarative format the Rust core reads, and it describes tables, columns, types, nullability, foreign keys, unique constraints, check constraints, and indexes, and from that single definition the core generates the query builder, the migration plan, and the constraint validation for all three languages, so when you write a typed query in TypeScript it compiles down to the same core call as the equivalent query in Python, and the constraint that rejects an invalid insert is enforced by the same Rust code in both cases, because there is only one case.
The query builder is schema-aware, which means it knows the shape of your tables at the point where it builds the query, not just at the point where it runs the query, so a column that does not exist is a compile-time error in Rust and, when the schema is encoded in TypeScript’s type system via literal string types and mapped types, a compile-time error there too, with Python falling back to a clear runtime error at query build time, not a silent NULL in a column you mistyped, and the builder pushes predicate and projection logic down to the native indexes rather than fetching rows and filtering in the client, which is the difference between a query that finishes in microseconds and one that finishes in seconds.
Migrations, defined once
Migrations are where multi-language SDKs usually drift the hardest, because a migration is a sequence of schema transformations that has to produce the same end state regardless of which client ran it, and if each language implements its own migration runner then the order of operations, the handling of defaults, and the semantics of a rollback are all places where implementations quietly diverge.
The Kit sidesteps this by making the Rust core the only thing that runs a migration, and the three language surfaces are thin wrappers that hand the migration file to the core and report back, so a migration that adds a column with a default, then backfills existing rows, then adds a unique constraint, runs the exact same operations in the same order whether you invoked it from mongreldb-kit-cli, from a TypeScript migration script, or from a Python Alembic-style runner, because the core is doing the work and the language is passing a message.
This is the same design principle as putting the query builder in the core instead of in each language, and it works for the same reason: a single implementation cannot disagree with itself.
Constraint enforcement lives in one place
A foreign key violation should produce the same error whether you inserted the row from Rust or from Python, and a unique constraint violation should report the same conflicting column, and a check constraint should evaluate the same expression with the same type coercion rules, and the only way to guarantee this across three languages is to enforce every constraint in the Rust core and let the language surfaces translate the error into something idiomatic without reinterpreting the semantics.
The Kit does this, and the payoff is not just consistency, it is testability, because you can write one fixture that inserts an invalid row and assert that all three languages reject it with the same error code, and that fixture is a real test that catches drift the moment someone tries to add language-specific constraint logic, which is the kind of thing a well-meaning contributor will attempt because it feels faster to validate in the language layer, and the fixture will stop them before it ships.
What you give up
The tradeoff is that the three language surfaces are constrained by what the core exposes, and if you want a query pattern the Rust core does not support, you cannot just add it in TypeScript, you have to add it in the core and expose it through NAPI and PyO3, which is more work than writing it once in the language you happen to be using. The surfaces are also less idiomatic than a native ORM would be, because they mirror the core’s API rather than reinventing it per language, and a Python developer who expects SQLAlchemy-style session management will find something flatter and more explicit.
This is the right tradeoff for a database client, where correctness and consistency across languages are the product, and idiom is a nice-to-have that ranks below “the same query returns the same result in every language.” A native ORM optimized for idiom is optimizing for the writer of the query, and the Kit optimizes for the reader of the bug report, which is usually a different person, usually at a worse time, and usually the one who has to figure out whether the bug is in their code or in the database.
The modern equivalent of this approach is the same thing SQLite has done for twenty-plus years: one C amalgamation, wrapped by every language under the sun, and the guarantee that a query plan behaves the same way regardless of whether Python or Go or Rust called it, because the engine is one piece of code and the bindings are thin. MongrelDB-Kit is that idea applied to a schema engine, and the reason it works is the reason it always works, which is that the cheapest way to keep three things in sync is to make them the same thing; the opposite model, where a specification pins behavior and each language implements it independently, is the C standard library, and that works only because ISO spent decades writing the spec, and we do not have that kind of time.
