Is convex just a postcest wrapper or a my SQL wrapper or a whatever wrapper? I still see this question coming up on discord and x [music] and to some degree I can understand the confusion. If you run a local copy of convex you can see that it has this sqite file here and if you crack that open then wow yeah there is all your data. So convex is just a wrapper of SQite then yeah right? Well, to prove to you that that's not exactly true, we're going to build a database like Convex from scratch. And along the way, we're going to find out exactly what the SQLite file is actually doing. And yeah, I did just say the same properties as Convex. So, [music] you know, it's got all that real time transactional safety goodness, but it's going to be entirely standalone and have no Convex code at all. So, hopefully by the end of this video, you'll be able to answer the question, isn't Convex just a Postgress wrapper? And spoiler alert, no. No, it's not. So, do make sure you grab yourself a lovely cup of tea, get yourself comfortable, and let's dive in and see if we can't build ourselves a convex from scratch. Right. So, just first off the bat, I want to say that everything in this video comes from this interactive article that I put out recently called build your own convex. If you want to poke and play with examples that we're going to show here yourself, then I've left a link to that article down below. So, this video is going to have 10 parts. Let's start with the basics. To begin with, let's build the absolute simplest database we could build. So firstly, what is a database? Well, it's got two main properties. It needs to remember things and [music] then give them back. But there are some other nice to have that we would like such as correctness, performance, audability, usability, and real time, etc. So, let's tackle remembering or storing things first. [music] And to do that, let's do the absolute simplest thing possible. Let's just when we insert a document, append [music] it to the end of a JSON file. So you can see I click it a few times. You can see the data is just getting added to the end of this JSON file. Okay, nice. So we've now stored our data in the database. Let's see how we get it back. So here we've got our database here and we can say find our plushy [music] and it's going to start at the top, read down to the bottom, grab the nine from it and it's going to say okay the value for the plushy the stock level is nine. And if we try and pick something that's not in our database yet, it's going to go through down to the bottom and it's going to say [music] it's not in there. Now this scanning of the database starting at the top and reading to the bottom is not a problem when we only have six documents in there. You know, computers are fast. But what happens if we have 6 million documents in there or [music] 6 billion documents in there? So, this is definitely going to be a performance problem. Let's just put a pin in that for now. I think we're going to fix it properly later. We've got a bigger issue to deal with first. Okay. So, we can write stuff to a database and read it back. So, we're done now, right? Well, yeah. No, not quite. We still need to handle what happens when we want to change something. So, let's take a look at the simplest way we could do that. We're going to find the place in our database JSON file we want to change it and just simply update it in place. So you can see it goes along, looks for the correct line, scans along to the correct character, and then updates that nine to a three. And we do it again. It's going to start at the top, go down to the bottom, scan along until we get to a three, change it back to or change it to a four. Now, this process would work, but the problem is it's not particularly efficient. files aren't really great for editing things [music] in the middle of them. So, to show you what I mean, if I click this stock change level, what we're going to want to do is change this nine to a 10. So, we've added one more stock to the item. But to do that, we have to insert another character, which means shuffling all these other characters down. So, let's try that out. [music] Yeah, there you go. You see? So, and we have to do that every single time we want to make a change to our JSON document inside of our database [music] JSON. So yeah, this could get pretty messy pretty fast. So editing data in the middle of a file isn't great. [music] What's much faster is if we just append data to the end of a file. [snorts] Let's have a look what happens there. So let's do that update again. We want to change the stock to seven and we just insert a new line at the end of the file. But now we've got two T's in here. You see we've got we say the stock is eight. We got [music] now Oh, sorry that was our mug. So we've got the mug of eight. Now a t of eight. [music] There we go. So now we've got three sets of t values in here. So what we've just done here is it turned our database JSON file into an append only log. So we only ever add data to the end of it. [music] And this is much more efficient than inserting and updating things in the middle of the file because appending data to an end of a file is a much more efficient operation. But the question naturally is how do we now actually get the current value for a given item in our database? Well, if you think about it, all we kind of actually need to do is just start at the bottom and then read back to the start until we reach the first document that we want. And then that is our latest value. So let's have a look. So if we're going to click find the hat and it's going to start at the bottom, read up until we get to our hat, get the stock value, and that's our [music] value. And now if we were to update our hat, it's going to add the update to the end and that's going to invalidate that line. So now when we read it again, it's going to grab just the first line, [music] which is great. And if we want to add a whole bunch more random items in [music] here, we can find the hat again, and it's going to have to read back through all of these to get to our hat. So it works. It's not particularly efficient, but we'll have a look at improving that in a minute. Okay, so this works for updating a single item, but it's not particularly realistic for what we likely want to do in the real world, is it? Instead of randomly, you know, setting a stock level like we were just showing examples there. What we usually want to do is do a couple of things at once. For example, we might want to reduce the stock level for an item when a user buys a hat and put it into their cart all at the same time. [music] And to do that, we're going to need to be able to combine reads and writes in a sequence. Let's have a look. So we [snorts] can see here we have we're reading the hat and the cart value and then we're setting both of them. So we're increasing the number of hats in the cart and we're decreasing the stock level of the hat. [music] So let's try this out. So we step forward. So we read the three comes in and then we [music] step forward. The next one in the cart is zero. So we pull that over and then we do the set. So that's going to increase that to one invalidating that last line. So that's the now the new value. [music] And then the stock level for the hat, we're going to decrease from three to two. So that's the new value there. So far so good. But can you spot the issue here? Pause the video if you want to have a think about it. Think you got [music] it? Let's just step through this again and see what happens. So we're going to do it again. Three is going to come in. Going to grab our hat value. We're going to grab the zero value is going to come in. We're then going to set our new cart value to one. And then we're just Oh, no, we're not because there was a power cut. So, we didn't actually get to complete our final line where we decrease the stock level for the hats. So, yeah, what we have now is corrupted data. The hat is both in the cart and in the stock, which is not good. You know what we need? We need both of those cart and stock level changes to happen at the same time. We need both of them to pass or both of them to fail. We don't want this kind of like halfway point. Fortunately though, 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 have a go now at wrapping both of our database rights inside a transaction block so that both of those changes either fail or succeed at the same time. So let's try it out now. So we grab our hat as usual. It's fine. We grab our cart as usual. That's fine. And now we're going to start this transaction block. So, it's going to buffer up the right. It's not going to do it immediately. It's going to buffer it up. And the second one is going to get buffered up. So, now it's going to send these two buffered um rightes at the same time. And they're both going to go in and hit the database at the same time. And they're both going to get added. This is great. Um but there is still a major issue lurking in here that's a bit more subtle and harder to find. [music] Can you see it? Let's just see what happens if we buy if we call that buy hat function [music] at the same time simulating coming from two different users at exactly the same time. So because both of them started the function at the same time, they both read exactly the same stock level and they're both going to read exactly the same cart level and they're both going to say, "Yep, there's hats in stock." So going to [music] carry on and they're both going to do the right transactions at the same time. Oh dear, they have both now claimed that the stock has gone to zero and they both think that they got the hat. That that's impossible. There's only one hat to be had. So the source of the problem is that we allowed buy hat to happen in parallel. We we allowed two of these functions to happen at the same time. So naturally the question is how do we fix this? Well and the simplest solution is just to not allow by hat to be called twice at the same time. [music] So if we make it singlethreaded then the [music] second user is going to get added to a queue while the first user just works [music] on um this function. So it's going through adding bits and pieces and then finally once user A is done user B can go on and do [music] its set of work and you'll see that it's grabbed the hat which uh there zero in the stock. So, it's going to get to the if statement here and it's going to see, okay, no hats [music] left. So, yeah, the purchase would have failed and the user would have got a nice error message hopefully. And this works, but we've really hurt the performance of our system, haven't we? We've really hurt it throughput. [music] And this problem would just get a lot worse if we had, you know, some operation in the middle of it that took a long time. For example, like we have our function here and we said do something that takes a long time. This calls some external service or it does some calculation, something that takes a while. And the problem is going to be the queuing the people that queue behind it are going to have to wait [music] until user A is finished before they can do their version of this. [music] So yeah, this is not ideal. So let's think a little bit harder about how we can solve this problem. Hm. What if we make it so that our transaction block is a little bit bigger so that we can expand it out so it includes not just the rights but the [music] reads as well. So now we have our reads within our transaction block and then we have our business logic which is you know checking the stock level and then we do our rights. Let's also add another requirement here is before our transaction can commit to the JSON file we first check to see whether any of the reads that we did within this transaction have [music] changed and if they have changed then that transaction is going to get entirely retrieded. Now, yeah, I know this is a lot to think through, but let's just have a look at an example and see what happens here cuz I think if we do this should we should be able to allow both of [music] these users to work in parallel once more. Okay, so let's try step forward. So, they both get the hat as the current stock level of one. [music] They also see their individual respective carts having value zero. So, they both think that they can write. Now the first user's transaction block writes [music] it gets accepted. The second user's transaction block is rejected because it sees that the hat value has changed [music] since when it was initially read in. So it's going to get retrieded which means it's going to pick up the new stock level of zero. And then we're obviously going to get to the the throw part of it. My friends, what we have just invented is the [music] convex mutation. This is uh what an actual convex mutation looks like. If you're not familiar with convex, it's [music] just a function that declares the arguments to it. In this case, our hat ID and our cart ID and the handler, which is our function body. And we're going to do a lookup in the database for the number of items. And we're going to check it stock level. No. And then we can throw there. Otherwise, we're going to look get our cart value. If there's no cart, we can throw. And then we'll just do our rights to both our cart and to the stock level. [music] So yeah, a comics mutation is just a transactional function. If anything fails in the transaction, we're going to abort the transaction as if it never ran. And you're guaranteed to never be in that no man's land of corrupted [music] uh state that we saw earlier. Now, the eagle eyes amongst you may have noticed a bit of slight of hand that I did earlier back here. How can this database [music] JSON which is you know a file on disk how can it check to see whether the reads you know don't conflict how does it actually check whether these reads have changed [music] or not and the answer is it can't you know it's it's just a file there's no kind of like anything protecting it it's writing to it what we really need is we need something to sit between our mutation you know our transactional function [music] and the database itself something that's going to perform these checks for us and only commit the transaction if nothing has changed. [music] And that magical thing is called the committer. So the committer is basically just a little service that sits between our mutation and the database. And it manages a queue of transactions that it can work on one at a time. And for a committer's [music] transaction to succeed, the committer must first ensure that none of the reads of the transaction have changed since the transaction began. To achieve this, the transaction is going to have to keep a track of the things that get read and things get written obviously. And these two things are called the read set and the right set. [music] Let's have a look at it in action. So we step forward. We're going to read the stock level of the hat. [music] So as one and then that read is going to get recorded in the read set. And we do the second one. We're reading the [music] stock the in the cart. And then that read is also going to get recorded in the read set. We're going to do our business logic and then we're going to do the rights which we don't do immediately because this is a transaction. We just record the fact that we want to write those things [music] in our right set. So now that we've got our read set recorded and our right set recorded, we can [music] pass that to the committer so it can do its thing. Let's have a look at the simulation to see what happens now when we have that bihat mutation running in parallel. So we've got user A mutation here, user B mutation here. These are going to read from the database uh and build up their read set and their right set like we just saw. And then they're going to talk to the committer which is going to sit [music] between the mutation and the database. And the committer's got its queue. So the first mutation has been added. So the first transaction has been added to the the committer's Q and the users B's transaction has been added to the committer's Q as well. So let's have a look. So the committer works on user A's transaction first. It checks to see whether the hat level is still what it is. It is. It's fine. It then checks to see whether the cart has changed. It hasn't. It's fine. So it says, "Yep, your reads are good. We're okay to do these rights into the database." And then that mutation is now done. It's succeeded. And we can return back to the user saying, "Congratulations, you got the hat." So then we get to work on user B's [music] transaction. We check, okay, is the hat still stock level still one? Like when when the mutation started? Oh no, it's not. It's now zero. Sorry, mutation B, you failed. And now you might be wondering, haven't we just slowed everything down again by making everything go into a queue inside of the committer? [music] And yes, to some extent, that's right. We have now reintroduced a single place where all the writes must go through in a sequential order. But unlike the mutations, the committer has a very narrow specific job that it needs to do. It needs to check the reads and then commit the rights. [music] And because we can keep that task very small, the code for the committ can be heavily optimized just for this one task and thus can smash through those transactions in its queue very quickly. And all of that complicated business logic can still go on inside the mutation in parallel. So that means like A and B can work on these in parallel at the same time. And it's just the final fast checks that can that have to happen serally. Okay. So now that user B's mutation has failed, what do we do next? How do we handle that? [music] Well, we could just reject the mutation and, you know, return an error back to the client saying, you know, you didn't get the hat. And that would work for this instance, but it's not a great experience in you in in general because there may still be more hats in stock. There's just like this particular transaction conflicted with another. So a better solution would be just to simply have [music] user B mutation just retry again. So that's what we're going to do here. It's going to retry again and it's going to get, you know, [music] no hats in stock left. There is a catch though. So to be able to safely have a mutation retry its function, its logic is going to have to be what's known as deterministic [music] and effect free. So by effect free, what that means is it must not have an effect on the outside world such as calling an external service like stripe or resend or something like that. To illustrate this a little bit more, let's take a look at another example. Let's have a look at what would happen if [music] we retried a function that had something effectual within it. So, it's the same function from before, the same mutation before, and everything's the same except for this one line here. So, after we've done the stock check, we're going to charge a user's credit card via Stripe. Let's have a look what happens. So, both the mutations run at the same time. They both get the hat value. Fine, we know that they're both going to check the the stock level. So, they both think they've got hats in stock. Fine. [music] Now, they're both going to do the effectual thing. They're both going to talk to Stripe. They're going to ping off a message to Stripe and say, "Hey, charge the users's card." So, that's both going to happen. Then they're both going to send their transactions to the committer to uh do the next step, which is [music] trying to write the database. So, users A's is going to succeed. Fine. Write to the database. Great. User A mutation succeeded. [music] All well and good. Everything's happy. User B's as we know is going to fail which means that user B's mutation is going to retry which means it's going to get the hat level and [music] then the next line the business logic is going to fail which means it's not going to get to the charging card part but oh we have already charged the user's card so uh-oh they [music] didn't get their hat so yeah this is a bit of a problem but I think you would agree that performing side effects is a pretty important part of any application [music] So without them, our app is going to be kind of limited in things that it could do. So the question is, how do we allow our mutation to call an external service like Stripe, but also keep the transactional and nice retribility property that we want. Actually, let's just park that thought for a second as I want to take a look at something we skipped past earlier first. So our database is starting to look pretty good. you know, rather than a simple dumb file that we just write edits to in the middle of it, it's now a log of transactions that the committer can approve or reject things being written to it. There is however an issue still. So we saw earlier that for the committed to approve a transaction, we must first make sure that nothing in the read set has changed. [music] And yeah, that makes sense. But we neglected to mention was how exactly does that nothing changed in the read set? What does that exactly mean? Seems simple, but as always, the devil is in the details. And the details here are quite subtle. So, let's take another look at an example. Now, let's suppose that the mutation started, you know, at this point. So, when the database was like here, it would have seen that the stock level was one. [music] Now, what happened later while that mutation was running is something else changed the stock level in the database. Now, we allow that. That's fine. have another mutation that changes it back to stock level to one [music] again. Again, we allow that as well. Now, when the mutation transaction tells the committer that it saw the stock level of value one [music] in the log, the question is when was it before these changes or was it after these changes? Was it here or here that it saw them? [music] So the committer can't tell that nothing changed because it's like nothing changed since [music] when. To fix this, we're going to have to introduce the concept of time [music] to our transaction log as well. So let's give committed transaction timestamp. 10 11 12 13. Now [music] let's also make it so that when a mutation begins it's given a start time and let's call that start time its snapshot time as it's like the snapshot or the view of the database at that instant in time. [music] So in this case it will be snapshot 10. Now when the mutation runs and runs its transaction and passes it to the committer [music] it's going to pass along its read set right set and the snapshot time. And now once the committer has that, the committer can definitively say whether something definitely has changed or not in the mutations readet because it knows when the mutation started. So here for example, you can see that no the the value has changed since our last snapshot. Okay, so our database is starting to look pretty good from a correctness standpoint. But remember those slow reads issue that we put away like way back in section one. Yeah, I think it's time that we address those now. [snorts] Up until now, we've been finding the latest version of a document by starting at the bottom and then reading back to the top until we find the one that we want. So, yeah, we get the hat, read it to the bottom, gets hat. The problem is every time somebody changes something else in the database, we're going to get a whole bunch more unrelated stuff between us and the thing that we want to read. [music] So, we try reading the hat again now. And it's got to go through all these other ones that have added until we get to this one, the one that we want. And we see we've now had to read 11 of 16 items just to get to our hat value. So, [music] not particularly efficient. We need a better way. What if we have a little lookup table that tells us where the latest version of each document lives? That way, we can instantly jump to the right place in transaction log to get the data that we need. [snorts] It will be a bit like a little table of contents that you get in an index in a book. You know, it allows you to jump to the right page in a given chapter. To be able to get that lookup table though, first we need to be able to build the index. So, we've got our big list of transactions here. First step is we need to build our index, which means it's going to go through one by one and work out what the current value is. Starting at the top and working our way down to the bottom. any values that have changed at later time stamps, it's just going to update the location to the new location in the transaction log. And the important thing to point out here is that this index is going to live inside memory rather than being a file on disk like we did with the transaction log. And [music] and this means that it's going to be really fast to look up like a dictionary lookup or, you know, when you look up a a key on an object in JavaScript. [music] So now that we've got our index built, we can look up find our hat. So we can say find our hat. It's going to go hat line six. Bam, there it is. Get the value straight back again. And we we didn't have to do any scan to to get our value. And similarly, if there's something that's not in our transaction log or in an index such as socks, it's going to say, "Nah, it's not in there. Sorry, there is no such thing as socks." [music] Now, this example is obviously pretty simplistic and just like everything in this video, it's probably a little bit more complicated in real life, but the basic idea is the same. We can make reads more efficient by storing an index first that points back to the original data. And just before we move on quickly, let's have a look at how an index actually gets updated. [music] So, it's just another role that the committer has. So for example, when we're patching the hat, it's going to enter the committer's transaction queue and the committer is going to say, "Okay, I need to update both the transaction and transaction log and the index at the same time." Okay, now there's still a bit of a complication that we need to talk about here. So now [snorts] when mutation A starts here, it's going to start at time stamp 10 for example. And when mutation B starts shortly after, it's going to enter the committer first and it is going to finish its work first, which means it's now inserted a new value at time stamp 11 into the transaction log and updated the index. [music] Now when A comes to read the hat, it's going to try and read the value at snapshot 10, which is the time that it started. But as you saw, it went into the index and looked up the value of the trans of the time stamp 11, [music] which is the wrong value. It should have looked it up at time stamp 10. What we need to do is we need to timestamp our index as well, just like we did with our transaction [music] log. So if we do that, we do the same thing again. We've now started mutation A at snapshot 10. B is going to run, enter into the committed queue. It's going to add its transaction to transaction log at time stamp 11. But we notice now we've got a versioned index. So we now have a time stamp 11 index in that points to [music] the uh index 11 in the transaction log. So now when mutation a runs it says okay give me the index that looked at at snapshot 10. So it's going to [music] go and get the transaction the value at snapshot 10 from the transaction log and return back the correct value. And yeah this works. It's great. You may see an issue with this though. Our index is going to have to keep track of all the changes to a given row over time. So this could get out of control quickly. Our index is going to get larger and larger and larger taking up more and more memory because don't forget the index lives in memory. So yeah, you can see here as we make a change to a hat, [music] the index is going to get bigger along with a transaction log which is uh not ideal. Let's just think about how we fix this a bit more. So we don't actually need to keep every index version forever, do we? We only need to keep around enough history for the reads from the oldest snapshot [music] that we need. So if we do that by limiting how far back the reads can go, we can just keep a moving window of the index history instead of allowing it to grow [music] forever. So for example, here we say at time zero, sure, there's been lots of changes to the hat. So, we've got six different changes to the hat in our index. Fine. But as time goes on, we can just have something that just kind of ages out the index and just cleans it up. So, those older versions can just get removed, keeping our index nice and snappy and taking up only as much memory as it actually needs. Okay, so now we have a simple transactional database that we can write things to and get the data back in an [music] efficient way thanks to indices. There's one other property that falls out nicely from this and that is subscribable queries. So a [music] query is like a mutation except it has no right set. Pretty simple. Let's let's have a look. [music] So we've got like this fake browser here hatshop.app and it's going to get the [music] current stock level for a hat. This is on the user's client. And then it's going to call into a query that's running on the server called get stock. Let's have a look what happens. So, it's going to call that the query is going to start at at time stamp snapshot 12 because that's what time it is. And then it's going to look go into the database, get that value two. It's going to come back and then it's going to record that in its read set. And then it's going to return that two back to the user on the client in the browser. [music] And we're going to be able to render that there's two hats in stock. Now the nice thing about this is if instead of the browser just calling this like once off we were somehow able to hold open this connection from the browser to this query instance [music] then we will be able to have the query tell the browser whenever the hat value changes then you can update. How how exactly do we do that? Well, if we had some sort of service, let's call it a subscription manager, for example, it could [music] keep track of these open queries and their given read sets and then could be notified whenever there's a change to the transaction log. And if any of those overlap with the reset, then we just have the query rerun and deliver the new value to the client. Let's [music] take a look. So, we've got two browsers here. The committer has is about to commit a new value for the hat. It [music] announces that the hat has changed. The subscription manager goes, "Oh, okay. That's going to intersect with both of these queries. So now we can send these two new values to the users browsers and [music] instantaneously update." And wow, it just works. It's it's amazing that this like powerful feature just falls neatly out of our transactional database. So now we have real-time queries along with our transactionbased mutations. What is there left for us to cover? Well, [snorts] we parked a problem earlier around how we might call external services from our mutations. So, I think let's take a look at that next. If you remember from earlier, we limited our mutations to being side effect free. That is, they can't talk to the outside world. And we did that because we need to be able to retry those mutations if they [music] fail. And we parked the question of how we were going to allow our application to talk to the outside world but still keep those nice transactional properties. [music] So let's just have a think about how if there is a way that we can achieve this. If we introduced [music] a new kind of function maybe let's call them actions then we could allow effects inside of it. They wouldn't be able to be transactional like mutations but we could allow them to call into mutations or queries [music] and wait for their return. and then at least they could still interact with the database in that [music] way. Let's take a look at what that might look like. So here we've got our browser again. We've got this checkout function and it's going to call this action, [music] this checkout action which is going to call our mutation to do our buy hat mutation and then we're going to charge Stripe after that. So [music] let's have a look at that in action. So we call a checkout, run a mutation. It's going to do that. Yeah, that got committed. Passed it back. Awesome. [music] No error. Now we can charge stripe. Now just note this is not how I would probably do it if I was rewriting real convex. I would probably use mutations as the entry point. I wouldn't usually allow the client call into actions. It's very rare that I allow that. Usually instead what I do is I have the client [music] call a mutation which inserts a like a record like a hold on the pending payment and then I would call out to Stripe have that come back. If that succeeds, I would release the hold and award the hat. Let's just keep this simple for this [music] now for now because this does work, but it is a little inconvenient that we do have to go via the action [music] first. What if instead we could have a mutation that could schedule the action itself [music] only if that mutation succeeds? Enter the scheduleuler. So, we've got a mutation again. We're going to buy a hat, do our business logic, write to a database, and then we're going to schedule an internal action to do the card charging. Let's try it out. So, we get our one goes into the read set. We get our stock goes to the right set and we get our scheduleuler go also goes into our right set. [music] So, the commit is going to write the database. This and then the item if it succeeds is going to go into the scheduleuler. The scheduleuler is going to run a loop that's going to eventually call this that schedule function which is going to call our action and we're done. We've now charged our user via Stripe. And if our committer was to fail for example, then that scheduleuler run isn't going to get inserted into the scheduleuler just like the rights and get rid into the database. So there's no effectual stuff happening. So there's no broken transaction or anything like that. All right. So, I hope you're still with me. And if you are, congratulations. You have just built all the most important parts of Convex. Congratulations. Party time. Yeah. From nothing, we've constructed the foundational elements that let us build a scalable, safe application that have [music] fantastic user experience. We have mutations that commit safely to transactional database. We have real-time subscript subscribable queries. And we have the ability to call the outside world via [music] actions. I think it's time for one last demo showing everything working together in this uh giant simulation thing here. So, we've got our two browsers, our user A and user B. [music] Um, and they're going to call mutations, queries, actions, we've got committers, subscription managers, we've got Stripe, we've got indices, we've got transaction logs, it's all going on here. Let's try it out. So, this guy buys one hat, goes through here. Let's speed this up because it's going to be too slow. [music] Faster, faster, faster, faster, faster. Right. Subscription manager updates. Yay, our hats bought. This uses uh browser here. So, we've only got one hat in stock. [music] Now, if we were to do, let me just slow down a second, make sure we can do this. If we were to click buy hat both at the same time on both of them, one of them should get the the buy and the other one should get an error. Let's try this out. So, buy by. So, it's going through. So, this one should succeed. Yes. And this one should error. Oops, we ran out of hat. Great. That's exactly what we wanted. We've got our nice consistent reliable transactional properties and we've got our subscribable queries. It's a thing of beauty. Now, obviously, there is a lot more we could talk about here. There are many other features in Convex such as, you know, full text search, vector search, components, files, and so on, but I think I've covered what are probably the most important things you need to know about Convex. So, let's just close the loop here. Let's go back to that original question we started with. You remember that SQLite file from the start? So let's crack that open again now. [music] So the majority of your data lives in this documents table and indexes just keeps track of [music] uh the index indices that you've defined. And if you were to host convex on Postgress or my SQL instead, it's the same story as my as SQite. So the documents table has an ID, a timestamp, and the document itself as a JSON. [music] It has a deleted flag and a previous time stamp and a few other things, but basically it's that this is our transaction log. So every table in your app, you know, your users, your messages, your hats or whatever, they all end up as rows of JSON in this one table. So SQI or Postgress is doing that job that DB.json file did [music] for us. It keeps the bite safe and does it in a nice, you know, reliable well battle tested way. And the transactions, you know, the committer, the snapshots, subscriptions, the scheduleuler, all of that is the convex stuff. [music] So no, convex isn't just a Postgress wrapper or an SQLite wrapper or a whatever wrapper. If anything, [music] the database underneath is just a fancy file basically. So next time somebody flippantly says, isn't convex just a Postgress wrapper? You know what to tell them. So [snorts] one last thing for me just before we wrap up here. This interactive blog post format is something new for me. It's something that I'm trying out. I want to do more of these as I personally find them a really great way to learn as it's much more fun to just poke and prod something something via examples. I find it really helps me kind of understand and grasp a topic a lot more than just sitting back and watching a video or reading a big long impenetrable blog post. And [music] with AI being as good as they are these days, building like something like this is a lot easier than it used to be. So, you know, why limit ourselves to those big boring walls of text and blog posts? So, would you be interested in seeing more of this kind of thing in the future? I'm thinking if this goes well, then I can do more of these interactive explainers uh and put them under this explain.dev site [music] as I think it's a great way for for those that are curious like myself um [music] to peek underneath the covers and just see how the sausage is made. Anyways, have a poke at the companion article for this video. I've left it in the comments down below. Let me know what you think. Be honest. I do want to know, you know, which of these demos worked for you, which of them didn't. just [music] give me some feedback. Any feedback is appreciated. And if you do like it and want to know when I drop more articles like this, I have this little subscribe option here that will shoot an email to you when a new one is ready. But anyways, I think that's all I have for you guys for now. Don't forget to drop me a like and sub. And until next time, thanks for watching. Cheerio.
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:
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:
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 formore 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});23// only runs if this mutation commits4await 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:
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:
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"67 ts prev_ts stock
8------------------- ------------------- -----
917936681596640960005.010179366815992610400017936681596640960004.011179366815995658300017936681599261040003.012179366815997806200017936681599565830002.013179366815998522000017936681599780620001.014179366816001436800017936681599852200000.015
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.
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.