A database wire protocol is the kind of decision you make once and then live inside for a decade, because every client you ever ship, every proxy you ever deploy behind, and every debugging session at 2am is shaped by it, and the industry default for thirty years has been to invent a binary protocol per database and then spend the next decade writing drivers for it. Postgres has its frontend/backend protocol, MySQL has its own packet format, Redis got away with RESP because it was simple enough to type by hand, and each of those choices created a whole ecosystem of client libraries that had to be ported, maintained, and debugged in every language somebody wanted to use. When we built the server mode for MongrelDB the tempting move was the same one, a compact binary framing over TCP with a Rust reference client, and we rejected it for a reason that has only gotten stronger since: plain HTTP with JSON bodies is already implemented, already proxied, already authenticated, and already debuggable with curl in every environment your code will ever run in, and the overhead it costs you is almost never where your latency budget actually goes.

That choice has a consequence worth stating up front, which is that the HTTP surface is not an accident or a shim, it is the contract, the same one the PHP client, the Kit SDKs, and your own shell scripts all speak, so it is worth walking through what mongreldb-server actually exposes and why the surface is shaped the way it is.

The shape of the surface

The daemon is an axum router, and the routes fall into a few deliberate groups rather than one flat pile. The operational group is what your monitoring talks to: /health for liveness, /build-info for the exact version and build metadata, /capabilities for what this build supports, /metrics for Prometheus-style scraping, /audit for the audit log, and /history/retention for reading and setting retention policy. Admin operations sit under /admin/drain and /admin/reload, with cluster membership workflows under /admin/cluster/* when the binary is built with the cluster feature, and that grouping exists so your firewall rules and your reverse proxy ACLs can treat “is it alive” traffic and “reconfigure the server” traffic as different trust levels, because they are.

The data group is the part a client touches on every request, and it starts with the table primitives: GET and POST /tables to list and create, DELETE /tables/{name} to drop, POST /tables/{name}/put to write a row, GET /tables/{name}/count, and POST /tables/{name}/commit. On top of that sits the SQL surface, which is where most real work happens, a single POST /sql that takes a JSON body and returns a JSON result, plus POST /sql/continue for paged results, GET /queries/{query_id} for status on a running query, and POST /queries/{query_id}/cancel when you want it dead.

The /sql request is the contract

The request body for /sql is where the interesting engineering lives, because a single POST carrying a SQL string is what every toy HTTP database does, and the difference between a toy and a transport is everything else in the envelope:

{
  "sql": "UPDATE products SET price = price * 0.9 WHERE category = 'clearance'",
  "format": "json",
  "query_id": "q-7f3a2c",
  "timeout_ms": 5000,
  "max_output_rows": 10000,
  "max_output_bytes": 4194304,
  "idempotency_key": "import-run-42-batch-7"
}

Every field except sql is optional, and each one exists because a real failure mode demanded it. timeout_ms and the output limits mean a runaway statement dies on the server’s terms rather than streaming gigabytes into a client that stopped caring, the query_id ties the request to the /queries/{query_id}/cancel endpoint so an operator or a watchdog can kill it from a different connection entirely, and idempotency_key is the storage-layer dedupe we covered in an earlier post, so a retry after a network drop replays the receipt instead of re-executing the write, which is exactly the property a batch import like the one above needs. The format field is a different kind of knob, a per-request serialization choice rather than a guard: JSON is the default, but "arrow" returns Arrow IPC file bytes instead, which is the honest answer to “JSON text is slow” for the analytical case where you are moving real row volume, same endpoint, same auth, same query, different bytes on the wire.

Reads ride the same envelope, and a paged SELECT adds a pagination block to the request: page_size_rows bounds by count, max_page_bytes bounds by serialized size, and max_page_tokens bounds by an estimated token count the server computes as ceil(projected_json_bytes / 4), which is a crude heuristic and deliberately documented as one in the response itself, but crude and server-side beats every client inventing its own truncation logic after the damage is done.

Cursors that tell the truth

A paged response comes back with the page and a continuation:

{
  "status": "completed",
  "rows": [ /* up to page_size_rows row objects */ ],
  "next_cursor": "9f2c...",
  "page": {
    "offset": 0,
    "row_count": 500,
    "total_rows": 18342,
    "byte_count": 241118,
    "estimated_tokens": 60280,
    "expires_at_ms": 1786963200000,
    "snapshot": "retained_result",
    "token_estimate": "ceil(projected_json_bytes/4)"
  }
}

Yes, the server really ships the formula itself as a literal string in every paged response, and that is deliberate rather than redundant: any client reading estimated_tokens can see exactly how the number was produced without opening our docs, so nobody mistakes the estimate for a precise tokenizer count.

You pass next_cursor to POST /sql/continue and get the next page of the same retained result set, which matters because the snapshot is held server-side, so page two sees the same data page one saw even if writes landed in between. The cursor is HMAC-signed with a process-local key, and that detail is a deliberate tradeoff rather than an oversight: a tampered cursor is rejected instead of being treated as a query, and a server restart invalidates outstanding cursors, which means a client must be prepared to re-run the query after a failover. We could have made cursors durable across restarts, but that would mean persisting retained result sets or making cursors replayable queries with all the consistency questions that brings, and the honest contract is the simpler one: a cursor is a short-lived handle to a snapshot this process is holding for you, with an expires_at_ms that tells you exactly how short-lived, and when it is gone you start over.

Transactions, sessions, and the typed routes

Single-statement SQL is not the whole story, so POST /txn takes a batch of operations and applies them atomically, which is how the Kit layer implements its transaction commit and how any client can get all-or-nothing semantics over a transport that has no concept of a connection-held transaction. For callers that want the classic prepare-then-execute shape, the /sessions group provides it over HTTP: POST /sessions opens a session, POST /sessions/{id}/prepare registers a statement, POST /sessions/{id}/execute runs it with bound arguments, and DELETE on the statement or the session cleans up, so prepared-statement caching and parameter binding work the way you expect without the transport needing to keep a socket open per client.

The /kit/* group is the typed surface the SDKs prefer, routes like /kit/schema, /kit/txn, /kit/query, /kit/search, /kit/retrieve, /kit/ann_rerank, and /kit/set_similarity, and the distinction from raw /sql is that these carry structured, validated payloads rather than SQL text, so the engine can enforce constraints and column types at the boundary and the client never has to serialize a query string. Stored procedures and triggers get their own REST-shaped resources, /procedures and /triggers with list, create, describe, replace, drop, and call, which keeps the admin-y operations discoverable with nothing more exotic than an HTTP client. And for the replication story, GET /wal/stream, GET /replication/snapshot, and GET /events are the streaming edges, the places where HTTP’s request-response shape bends into a long-lived stream because change capture genuinely is a stream.

What HTTP costs, and what it buys

The costs are real and worth naming. Every request carries headers and JSON parsing that a binary framing would shrink, there is no server push outside the streaming endpoints, and keep-alive and connection reuse are your responsibility through whatever HTTP client you bring, which is why the PHP client’s persistent cURL sharing on 8.5+ matters as much as it does. The server meets you halfway on the abuse cases: a request-bytes bound rejects oversized bodies with a structured 413 rather than an OOM, and an optional concurrency limit sheds load instead of queueing until the wheels come off.

What it buys is everything you did not have to build. Auth is middleware with three modes, a bearer token, HTTP Basic verified against the Argon2id-hashed catalog users, or both, and TLS, rate limiting, and request logging come from whatever proxy you already run rather than from database-specific features somebody has to reimplement. Debugging is curl from any machine that can reach the port, load balancing is the same layer-7 infrastructure the rest of your stack uses, and a new language gets a working client the day somebody writes a hundred lines around its standard HTTP library, which is exactly how the long tail of MongrelDB language clients exists at all. The twenty-year view is that every database that invented a wire protocol also invented a driver maintenance burden it is still paying down, and the modern equivalent of the mysql_* extension problem is a proprietary protocol that only three of your twenty supported languages talk fluently; HTTP is not the fastest transport you can imagine, it is the fastest one you can already use from everywhere, and for an engine whose whole pitch is meeting you where your code runs, that is the right trade.