Mike Cann's avatar
Mike Cann
25 days ago

Let's build a database!

Is Convex just a Postgres wrapper? Or a MySQL wrapper? Or a whatever wrapper?

I still see this question come up on Discord and X, and to some degree I can understand the confusion. If you run a local copy of Convex you'll find a SQLite file sitting there, and if you crack it open then wow, there is all your data. So Convex is just a wrapper of SQLite then, right?

Well to prove to you that's not exactly true we're going to build a database from scratch, and along the way we'll find out exactly what that SQLite file is actually doing. This database is going to have the same properties as Convex, you know, all that realtime, transactional safety goodness, but it's going to be entirely standalone with no Convex code at all.

Here's where we're going to end up. The two windows on the left are two people clicking "buy" on the last hat at the same moment, and everything else on screen is a piece we're about to build. Keep an eye on those two windows, as only one of them gets the hat:

0:00 / 0:00

Everything here comes from an interactive article I put out called Build Your Own Convex, so if you want to poke and play with these demos yourself you can do that over there. I was heavily inspired by nan.fyi's interactive database post by the way, which is well worth a look if you haven't seen it.

So grab yourself a lovely cup of tea, make yourself comfortable and let's dive in.

What's the simplest database we could build?

A database has two main jobs. It needs to remember things, and then give them back. A really good database has a bunch of other properties we care about too, like correctness, performance, auditability, usability and realtime, but let's not worry about those for now.

Let's tackle remembering first, and let's do the absolute simplest thing possible. Say we're building a little online shop, and each thing we sell is a document, which is just a bit of JSON. When we insert a document we just append it to the end of a file, db.json:

0:00 / 0:00

Okay, nice, we've stored some data in our "database". Now let's see if we can get it back. The simplest way to do that is a scan, where we start at the top and read line by line until we find what we're looking for. Here I look for the plushie, then for socks, which we don't sell:

0:00 / 0:00

Scanning like that is no problem with 6 documents, computers are fast, but what if we had 6 million? Let's put a pin in that for now, as we've got a bigger issue to deal with first.

How do we change something?

So we can write stuff to our database and read it back. Database done now, right? Not quite. We still need to handle what happens when we want to change something, and the simplest way would be to find the right place in the file and update it:

0:00 / 0:00

That works, but files aren't really great at editing things in the middle of them. Say we want to change a stock level from 9 to 10. That's one extra character, which means shuffling along everything after it. Here's the file one character at a time, so watch what happens after the 9:

0:00 / 0:00

This could get messy pretty fast. What's much faster is to only ever add data to the end of the file, so let's do that. An update is now just a new line at the bottom:

0:00 / 0:00

So we've just turned our database into an append-only log. The question now is how do we get the current value for an item, when it could be in the file more than once? Well if you think about it, all we need to do is start at the bottom and read back up until we reach the first document we want, and that's the latest value:

0:00 / 0:00

It works, it's not particularly efficient, but we'll get to that.

What if the power cuts out halfway through?

Now this is fine for updating a single item, but it's not very realistic is it? What we usually want to do is a couple of things at once. When a user buys a hat we want to reduce the stock level and put the hat in their cart, so we need to combine reads and writes in a sequence:

1async function buyHat() {
2  const hat = await db.get("hat");
3  const cart = await db.get("cart");
4  await db.set("cart", { hats: cart.hats + 1 });
5  await db.set("hat", { stock: hat.stock - 1 });
6}
7

Simple enough, right? But can you spot the issue? Let's step through those 4 lines and see what happens if there's a power cut right before the last one:

0:00 / 0:00

Uh oh. That last write never made it, so now we've got corrupted data. The hat is both in the cart and in the stock.

What we need is for both of those changes to pass or both of them to fail, with no halfway point. Fortunately we aren't inventing anything new here. This is what database folks call a transaction, and the all-or-nothing property is called atomicity. So let's wrap both writes in a transaction block, which buffers them up and then sends them to the database together:

0:00 / 0:00

Great, that fixes the power cut, but there's still a major issue lurking in here that's a bit more subtle and harder to find. Can you see it?

What if two people buy the last hat at once?

So we don't sell hats we don't have, let's add a check to buyHat that throws if the stock is 0. Now let's see what happens if two users call it at exactly the same time, with one hat left in stock. User A is the top box and user B is the bottom one:

0:00 / 0:00

Oh dear. They both read the stock at the same time and saw 1, so they both think they got the hat, and there's only one hat to be had.

The source of the problem is that we allowed two people to run buyHat at the same time. So naturally the simplest solution is to just not allow that. If we make it single-threaded then user B gets added to a queue while user A finishes up, and by the time B gets a turn there are no hats left, so they get a nice error message instead:

0:00 / 0:00

This works, but we've really hurt the throughput of our system, haven't we? If buyHat had something in it that took a while, like some big calculation, then everybody in the queue would have to wait for that as well.

So let's think about this a bit harder. What if we make our transaction block bigger, so that it includes not just the writes but the reads too? And let's add a new requirement: before a transaction can write to the JSON file, we first check whether anything it read has changed. If it has, the whole transaction gets retried.

I know this is a lot to think through, but it means both users can run at the same time again. So here A's transaction gets accepted and B's gets rejected, because the hat has changed since B read it. B then retries, reads the new stock of 0 and throws:

0:00 / 0:00

My friends, we have just reinvented the Convex mutation. This is what a real one looks like:

1// convex/shop.ts
2import { v } from "convex/values";
3import { mutation } from "./_generated/server";
4
5export const buyHat = mutation({
6  args: {
7    hatId: v.id("items"),
8    cartId: v.id("carts"),
9  },
10  handler: async (ctx, args) => {
11    const hat = await ctx.db.get("items", args.hatId);
12    if (!hat || hat.stock <= 0) {
13      throw new Error("No hats left");
14    }
15
16    const cart = await ctx.db.get("carts", args.cartId);
17    if (!cart) throw new Error("Cart not found");
18
19    await ctx.db.patch("carts", args.cartId, {
20      hats: cart.hats + 1,
21    });
22    await ctx.db.patch("items", args.hatId, {
23      stock: hat.stock - 1,
24    });
25  },
26});
27

A Convex mutation is just a transactional function. If anything fails we abort the transaction as if it never even ran, so you never end up in that no man's land of corrupted state from earlier, and if it conflicts with another transaction it just gets re-run.

I had my agent try this on a local deployment, with 5 hats and 20 people all calling buyHat at once. Exactly 5 of them got a hat, the other 15 got "No hats left", and the stock finished on 0. Noice!

Now the eagle-eyed amongst you may have noticed a bit of sleight of hand back there. How can db.json, which is just a file on disk, check whether a transaction's reads have changed? The answer is it can't, it's just a file. We need something to sit between our mutation and the database that does these checks for us, and only commits the transaction if nothing has changed.

Who checks that nothing changed?

That something is called the committer. It's a little service that manages a queue of transactions and works on them one at a time.

To be able to check them each transaction needs to keep track of what it read and what it wants to write, and these are called the read set and the write set. Notice in this one that the writes don't touch the database, they just get recorded:

0:00 / 0:00

Now we can hand those to the committer. Here are our two users again, with A on top and B underneath, both buying at the same time. A's transaction is first in the queue, its reads check out, so its writes go into the database. Then it's B's turn, the hat isn't stock 1 any more, and B gets rejected:

0:00 / 0:00

You might be wondering, haven't we just slowed everything down again by making everything go through a queue? And yes, to some extent that's right. We've reintroduced a single place where all the writes have to go in order. But unlike a mutation, the committer has a very narrow job, which is to check the reads and commit the writes. Because that job is so small the code can be heavily optimised and smash through its queue really quickly, while all of your complicated business logic still runs in parallel in the mutations.

Oh and why retry user B at all, rather than just returning an error to the client? Well in general that's not a great experience, as there may still be plenty of hats in stock and B just happened to conflict with someone else, so it's better to have B simply try again.

There is a catch though. To be able to safely retry a function it has to be deterministic (the same inputs always give the same result) and effect free, which means it can't have an effect on the outside world, like calling Stripe. To see why, let's add one line to buyHat that charges the user's card before the writes:

0:00 / 0:00

Both mutations think there's a hat so they both charge the card. Then A commits, B gets rejected, and when B retries it finds no hats left. But we've already charged B, so that's $20 for no hat. And if there had been more hats in stock, the retry would have charged them a second time.

Real Convex doesn't let you make this mistake by the way. If you try calling fetch in a mutation, Convex is going to yell at you:

1Uncaught TypeError: fetch to https://api.stripe.com/v1/charges failed: Can't use fetch() in queries and mutations. Please consider using an action. See https://docs.convex.dev/functions/actions for more details.
2

I think you'd agree that side effects are a pretty important part of any application though, so how do we call something like Stripe and keep that nice retry behaviour? Actually, let's just park that thought for a second, as I want to look at something we skipped past first.

Nothing changed... since when?

So rather than a dumb file that we edit in the middle, our database is now a log of transactions that the committer approves. We said the committer has to make sure nothing in the read set has changed. Makes sense, right? But as always the devil is in the details, because what does "nothing changed" actually mean?

Suppose a mutation reads a hat with a stock of 1. While it's still running, another mutation changes the stock to 2, then another changes it back to 1. Our mutation now tells the committer that it saw a stock of 1, but was that the first 1 in the log, or the one after those changes? In this clip I'm highlighting the first one, then the later one, and they look exactly the same, so the committer can't tell whether the hat changed while our mutation was running:

0:00 / 0:00

So nothing changed since when? To fix this we need to introduce the concept of time. Let's give every committed transaction a timestamp, and let's also give each mutation a start time when it begins. We'll call that its snapshot timestamp, as it's the view of the database at that instant.

Now the mutation passes that along with its read set and write set, and the committer can say for sure whether something changed, because it knows when the mutation started. Here the mutation's snapshot is 10, so when the committer gets to the hat written at 11 it knows there's a conflict:

0:00 / 0:00

How do we find things without reading everything?

Okay so our database is looking pretty good from a correctness standpoint, but remember that slow reads issue we put a pin in way back at the start? Every time somebody changes something else, there's more unrelated stuff between us and the thing we want to read.

What if we keep a little lookup table that tells us where the latest version of each document lives? It would be a bit like the index in a book, which lets you jump straight to the right page. We go through the log once to build it, and after that finding the hat is instant, and if something isn't in there, like socks, we know straight away:

0:00 / 0:00

In our database the index lives in memory, so a lookup is just like looking up a key on an object in JavaScript, and the committer updates it at the same time as it writes to the log. It's obviously a little more complicated in real life, but the basic idea is the same.

Now there's a bit of a complication. Say mutation A starts at timestamp 10, and while it's still running mutation B starts, finishes and gets committed at timestamp 11. When A gets round to reading the hat it wants the database as it was at 10, but our index only points at the latest version, so A ends up reading a stock of 0 from the future:

0:00 / 0:00

What we need to do is timestamp our index, just like we did with the log. Now A looks up the newest version at or before its snapshot and gets back the stock of 1 that it should have seen:

0:00 / 0:00

You may see an issue with this though. If the index keeps track of every change to a document forever then it's going to take up more and more memory. But we don't actually need every version do we? We only need enough history for reads from the oldest snapshots, so if we limit how far back those reads can go then we can keep a moving window of versions and clean up the rest. As I move this slider, watch the old versions drop out of the window:

0:00 / 0:00

If you want to see what happens when something does need to read further back than that window, I did a whole whiteboard session with Nipunn on exactly that.

How does the browser hear about changes?

So now we have a transactional database that we can write to and read from efficiently, and there's one other property that falls out of this really nicely, which is subscribable queries.

A query is just like a mutation except it has no write set. Pretty simple. Here a browser calls a getStock query, which gets a snapshot timestamp, reads the hat, and ends up with the hat in its read set:

0:00 / 0:00

Now what if, instead of calling that once, the browser could hold the connection open? If we had some sort of service, let's call it a subscription manager, it could keep track of those open queries and their read sets, and get told about every commit. If a commit overlaps with a query's read set then we just re-run that query and send the new value to the browser. Here two browsers are both watching the hat when a new stock level gets committed:

0:00 / 0:00

And wow, it just works. It's amazing that this falls so neatly out of our transactional database.

How do we charge a card safely?

Right, back to that problem we parked. We limited mutations to being side effect free because we need to be able to retry them, so how do we talk to the outside world?

If we introduce a new kind of function, let's call it an action, then we can allow effects inside of it. It wouldn't be transactional like a mutation, but it could call into mutations and queries and wait for them to return, so it can still interact with the database that way. So here the browser calls a checkout action, which runs the buyHat mutation, waits for that to commit, and only then charges Stripe:

0:00 / 0:00

It does work, but it's a little inconvenient having to go via the action first, and the docs call that an anti-pattern in most cases. What if the mutation could schedule the action itself, but only if the mutation succeeds? Enter the scheduler.

The scheduled call goes into the write set along with everything else. So in this next one, chargeCard is a little action that calls Stripe. Watch it show up in the write set, then land in the scheduler's queue once the transaction commits. If the committer rejects the transaction then nothing gets scheduled, and we can retry as many times as we like:

0:00 / 0:00

In real Convex that's just a line at the end of your mutation, and the docs guarantee it's atomic with the rest of the mutation:

1await ctx.db.patch("items", args.hatId, { stock: hat.stock - 1 });
2
3// only runs if this mutation commits
4await ctx.scheduler.runAfter(0, internal.shop.chargeCard, {
5  cartId: args.cartId,
6});
7

Super cool! For real payments I would more likely go a step further and have the mutation record a hold on the hat pending payment, then call Stripe, and if that succeeds release the hold and award the user the hat. But let's keep it simple here.

Does it all work together?

If you're still with me then congratulations, you have just built the most important parts of Convex! Here's that giant demo from the start again, this time with one person buying a hat. It runs through the mutation, the committer, the log and the index, then the subscription manager updates both browsers and the scheduler charges the card:

0:00 / 0:00

There are obviously plenty of other things in Convex that we haven't covered, like text search, vector search, components and file storage.

So, is Convex just a Postgres wrapper?

Let's go back to that SQLite file from the start. I had my agent spin up a local deployment and run that test from earlier, with the 20 people and the 5 hats, and then crack the file open. There isn't much in there:

1$ sqlite3 .convex/local/default/convex_local_backend.sqlite3 ".tables"
2documents     indexes     persistence_globals
3

persistence_globals is a bit of Convex's own bookkeeping, and your data lives in the other two. So here's the documents table, filtered down to that one hat:

1$ sqlite3 -header -column .convex/local/default/convex_local_backend.sqlite3 \
2  "select ts, prev_ts, json_extract(json_value, '$.stock') as stock
3   from documents
4   where json_extract(json_value, '$._id') = 'j97dj90xexg3azakdt4xmpnd5s8hghar'
5   order by ts"
6
7        ts                 prev_ts        stock
8-------------------  -------------------  -----
91793668159664096000                         5.0
101793668159926104000  1793668159664096000    4.0
111793668159956583000  1793668159926104000    3.0
121793668159978062000  1793668159956583000    2.0
131793668159985220000  1793668159978062000    1.0
141793668160014368000  1793668159985220000    0.0
15

Look familiar? Nothing got edited in place. Every change is a new row with a timestamp, which also points back at the previous timestamp. That's our transaction log! The documents table has a few other columns too, like the document's ID, its table and a deleted flag, and every table in your app (your users, your messages, our hats) ends up as rows of JSON in here.

The indexes table has an index ID, a timestamp, a key and a pointer to a document, which is our versioned index. The one difference from our little demo is that these indexes are stored in the table, rather than only living in memory.

If you self-host Convex on Postgres or MySQL instead it's the same story, and you can see the tables it creates in the open source backend.

So SQLite, or Postgres, is doing the job that our db.json did. It keeps the bytes safe, and does it in a nice battle-tested way. The committer, the snapshots, the subscriptions and the scheduler, all of that is Convex.

So no, Convex isn't just a Postgres wrapper. Or a SQLite wrapper. Or a whatever wrapper. If anything, the database underneath is just a fancy file. So next time someone asks you, now you know what to tell them.

Conclusion

So just to recap: we started with a JSON file and kept fixing whatever broke, adding transactions, a committer, timestamps and indexes to keep it correct and quick. Subscriptions then came along pretty much for free, and actions and the scheduler let us talk to the outside world. Underneath all of that, SQLite or Postgres is just keeping the bytes safe.

This interactive format is still pretty new for me and I'm just trying it out. I personally find it a really great way to learn, as it's just a much more fun way to find out about something by poking and prodding it yourself. So do have a play with the demos. If you want to go deeper on the real thing, Sujay wrote a great post on how Convex works too.

Until next time, cheerio! 👋

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started