Every project that ships the same SDK in three languages eventually ships three different SDKs, because the implementations drift the moment two humans edit two files on two different days, and the drift never announces itself; it shows up as a Python job that silently accepts a row the TypeScript service would have rejected, or a filter that returns rows in a different order depending on which side of the stack asked. I lived through this in the jQuery years with server frameworks that promised identical behavior across their PHP and Ruby ports, and the promise was always true for about one release, after which the changelog diverged and the docs quietly stopped mentioning the smaller port. When we built MongrelDB Kit, the persistence layer over the MongrelDB engine, the design constraint was that “same schema, three SDKs” had to be a mechanical fact rather than a marketing sentence, and the way you make it mechanical is you give the three languages one core and one test corpus, then let CI embarrass whichever SDK drifts first.
One core, three thin surfaces
The first decision was to refuse to reimplement anything per language. The Rust crate mongreldb-kit is the actual implementation: schema catalog, query builder, migrations, constraint enforcement, all of it. The Python package is a PyO3 binding built with maturin over that same crate, and the TypeScript package talks to the same compiled Rust core through native Node bindings, so the three “SDKs” are really three grammars over one semantics. A filter you build in TypeScript compiles to the same internal plan as the identical filter in Python, because there is only one compiler, and the schema catalog lives in the database itself with stable table and column ids, so all three languages are reading and writing the same descriptors rather than their own interpretation of them.
That architecture is what makes the interesting question askable at all, because once the semantics live in one place, “do all three SDKs behave the same” stops being a philosophy and becomes a diffable output.
The conformance suite is the contract
The answer to that question lives in tests/conformance/, which is a set of language-neutral JSON fixtures plus three small runners, one per SDK, that execute the same scenarios and compare against the same expected output. The fixtures are deliberately boring to read, which is the point; here is a slice of queries.json:
[
{ "name": "query_all_users", "table": "users", "order": "+id" },
{ "name": "query_users_by_role", "table": "users",
"filter": { "role": "admin" } },
{ "name": "query_posts_by_user", "table": "posts",
"filter": { "user_id": { "eq": 1 } } },
{ "name": "query_users_limit_offset", "table": "users",
"order": "+id", "limit": 1, "offset": 0 }
]
Every scenario is named, and for every name there is an expected result under fixtures/expected/, so a runner’s job is just: load the fixture, apply it through the public SDK surface, serialize what came back, compare. The Rust side is a workspace member called conformance-runner that you can also run as a standalone binary, the TypeScript side is a vitest file that picks the suite up automatically in npm test, and the Python side is a pytest module that loads the same JSON files from the same directory. Nobody wrote the same test three times, which matters, because the moment you write the same test three times you have written three different tests and you are back to drift with extra steps.
The fixtures test the failures, not just the happy path
The part I care most about is that the corpus spends as much energy on what must not work as on what must. schema.json defines users, posts, and comments with unique constraints, check constraints, and foreign-key actions across cascade, set-null, and restrict, and then inserts.json and deletes.json include rows that are supposed to fail, with the expected error recorded by code rather than by message text. The Python runner, for instance, maps its exception types onto the shared vocabulary and asserts DUPLICATE where the Rust runner asserts DuplicateError; what is being tested is that all three languages reject the same row for the same reason, not that they phrase it identically. There are fixtures for aggregates, CTEs, joins, learned-range indexes, migration failure handling, byte-prefix filters, and encrypted tables, and each one lands in all three runners at once, so a new engine behavior is not “supported in the SDKs” until the fixture exists and all three suites pass it in the same CI run.
That is the real product feature hiding under the word “conformance”: when a team mixes the TypeScript service, a Python analytics job, and a Rust batch worker against the same database, the behavioral differences between those processes are held at zero by the suite rather than by hope; the bindings still own their own serialization and error mapping, which is exactly what the fixtures exist to police, and when a bug report says “the Python client let this through,” the first thing CI tells you is whether that is even possible.
What this does not buy you
Honesty about the seam: conformance proves the SDKs agree with each other and with the fixture corpus, and it does not prove the corpus is complete, so the failure mode moves from “the SDKs drifted” to “the fixtures have a blind spot,” which is a better failure mode because it has exactly one place to fix. It also costs something; every new engine feature owes three green runners before it ships, which slows the merge queue compared to a single-language library, and the error-code normalization layer (mapping Python exception types onto the shared vocabulary) is the kind of unglamorous plumbing that has to be maintained forever. I still think it is the right trade, and the comparison I keep coming back to is the old LAMP-era habit of testing only the primary driver and treating the other bindings as community goodwill: that worked until it didn’t, usually on a Friday, and the modern equivalent of doing it right is not more discipline in three repos, it is one corpus that all three repos are forced to agree with.