Using search as a primary datastore since the docs said not to

Monday, August 26, 2024

Look, I'm sorry, but if the docs say not to do something that's like catnip. Then I just have to do it. So when I saw that the Typesense docs say not to use it as a primary datastore? Well well well, that's what we'll have to do.

I spent a little bit of time figuring out what a bad but plausible use-case would be. The answer is: a chat app. Most chat apps have a search feature, so if you use search for the primary datastore, you get to remove another component!

Note: this is a sponsored post. I was paid by Typesense to write this post. The brief was to use Typesense in a small project and write about it, the good and the bad1. They have not reviewed this post before publication.

What does the chat app look like?

One of life's hard problems is naming things. This chat app, like all Super Serious Side Projects, needs a fitting name, and so I arrived at: Taut. It's named such because it is chat, but it sure ain't Slack.

The build-out was pretty straightforward and you can see the repo on GitHub. It's licensed under the AGPL, and you should almost certainly not reuse this code—I'm doing what you're not supposed to! But it's open-source, so feel free to draw inspiration from it or use it as an example of how to use the Go SDK for Typesense.

Amusingly, the repo stats show that CSS is what I have the most of. The Go backend for this is pretty simple, and the JS is non-existent since I used htmx. Most of that CSS is not hand-written, though, since I used Tailwind.

Here's what the finished app looks like. We have a login screen, which has no password requirement because this is for trustworthy people only! You enter your handle and then you're logged in.

Once you're logged in, you see a chat interface! Here's a chat between two of our characters, Nicole and Maddie.

And here's another, between Nicole and Amy, who are apparently coworkers.

Oops, it looks like Nicole is going to put corporate details into this chat app! I guess we'd better look at how it's implemented to see if that's okay.

Modeling our data

The first thing we need for our web app is a data model. For our chat app, we really need two main things: users and messages. Each user should have a handle, and each message should have who it's from and to as well as what was said.

I ended up with these models:

type User struct {
	ID      string `json:"id"`
	Handle  string `json:"handle"`
	Credits int64  `json:"credits"`
}

type Message struct {
	ID string `json:"id"`

	Sender    string `json:"from_id"`
	Recipient string `json:"to_id"`

	Content   string `json:"content"`
	Timestamp int64  `json:"timestamp"`
}

(Ignore "Credits", sssshhh, we'll come back to that.)

To get records into the datastore, we also have to configure our schema. There are some auto-schema settings available, but I wasn't sure how that worked and I want to be certain which schema is picked up, so I went with the old trusty to define how my data is laid out. It's pretty straightforward: you tell it what fields you have and what their types are, and then you're done. The ID field is created for you automatically, so you can leave that one off.

Here's an example of creating the users schema.

ctx := context.Background()

userSchema := &api.CollectionSchema{
  Name: "users",
  Fields: []api.Field{
    {
      Name: "handle",
      Type: "string",
    },
    {
      Name: "credits",
      Type: "int64",
    },
  },
}

_, err := ts.Collections().Create(ctx, userSchema)
if err != nil {
  return err
}

You'd do something similar for any other collection. This isn't too bad, but it's a bit redundant with what we already defined in the struct. There could be an opportunity for some languages to auto-generate this for you, though the typesense-go library doesn't.

Creating records is where we start to see why what we're doing is probably a bad idea. I only want to create a record if there isn't a user already. In relational databases (especially with an ORM), this is a succinct operation. Here, it gets a little more verbose.

We retrieve all the existing users by querying by the user's handle.

ctx := context.Background()
query := api.SearchCollectionParams{
  Q:       pointer.String(handle),
  QueryBy: pointer.String("handle"),
}

matchingUsers, err := ts.Collection("users").Documents().Search(ctx, &query)
if err != nil {
  return err
}

Then we count how many there are, and if there is not already a user, we create one!

if (*matchingUsers.Found) > 0 {
  return nil
}

id := handle
user := User{
  ID:      id,
  Handle:  handle,
  Credits: 100,
}
_, err = ts.Collection("users").Documents().Create(ctx, user)
if err != nil {
  return err
}
return nil

A natural question may be, why not use an Update operation, or upsert if it's available? I wanted to do something like this, but this will udpate the document we provide if it already exists! There's no create-if-not-exists that I could find, and I didn't want to reset that Credits field.

We do similar for messages, which is in models.go. Now we have our models, and we can create instances of them!

Building the views

Everything is a single-page app these days and doesn't need to be, so I built this in a traditional client-server way. But since it's, you know, chat, it has to be more interactive. That's easily addressed with htmx to make things reload! I did polling here for simplicity, but you can also do it over websockets, which would be the better approach.

The login view isn't too interesting, but the main chat view and search views are where we see the meat. Let's look at the chat view first.

Since we're using htmx, we'll implement fragments of views, which we'll load to replace specific parts of the page. This led me to write the views in a modular way, and really reminded me how good we have it with other template libraries, and how bare-bones Go's built-in html/template library is.

The main view looks like this. Ignoring the html_open and html_close templates, there's not a lot to it. Just some divs with styles and invoking the templates for our user list and chat window.

{{ template "html_open" }}
<main class="w-full h-full">

<div class="flex flex-col w-full h-full p-4 bg-flagpink">
{{ template "header" . }}
  <div class="flex flex-row h-full w-full">
    {{ template "user_list" . }}
    {{ template "chat_window" . }}
  </div>
</div>
</main>
{{ template "html_close" }}

Each of those is also pretty simple. This is how the user list is populated. Each user has a handle, and clicking on their handle will let you chat with them.

{{ define "user_list" }}
<div id="users-list" class="bg-white outline outline-4 outline-black h-full p-2 flex flex-col" hx-get="/fragment/users" hx-trigger="every 5s" hx-swap="outerHTML">
  <strong>People</strong>

  {{ range .Handles }}
  <a href="/start-chat/{{ . }}">{{ . }}</a>
  {{ end }}

</div>
{{ end }}
{{ template "user_list" . }}

On the backend, we have to get data into these views, though. To do that, we're off to query Typesense again! Full details are in the views.go file, here are the highlights.

The main thing we need to do is list users. We can make a function to return that list, which will take a Typesense client as an argument (here, it's called h.Ts due to either idiomatic or poor naming conventions).

handles, err := ListUserHandles(h.Ts)

Implementing this is pretty easy. We search for all users, retrieve them, then from each record we extract just the handle.

func ListUserHandles(ts *typesense.Client) ([]string, error) {
	ctx := context.Background()

	query := api.SearchCollectionParams{
		Q:       pointer.String("*"),
		QueryBy: pointer.String("handle"),
	}

	userRecords, err := ts.Collection("users").Documents().Search(ctx, &query)
	if err != nil {
		return nil, err
	}

	handles := make([]string, 0)

	for _, userRecord := range *(*userRecords).Hits {
		handle := (*userRecord.Document)["handle"].(string)
		handles = append(handles, handle)
	}

	return handles, nil
}

Not bad, given what we're doing! It could be shorter, but this is a raw library, so it's not too tough to wrap that up yourself.

Displaying messages got... sketchy

You're going to run into cases like this when you hold something wrong on purpose, but yeah, I really stepped in it with message retrieval. What we wanted was to display all the chat messages between two users, and what we got was definitely that, but then also maybe something spicy. To make it work, I definitely misused the query interface.

When you query Typesense, you get a few parameters.

  • q is the search string. For a recipes app, this might be "pizza".
  • query_by says which field to search for q within. This could be something like "description".
  • filter_by lets you provide some criteria to filter out non-matching records. This could be num_steps:<5, because I want a simple pizza recipe.

Now, here's what I wound up with to search for messages. Remember that our messages have a sender, recipient, and content. Here we're just looking at messages from one user to the other, so we'll just get one side of the conversation.

filter := fmt.Sprintf("from_id:=%s", from)

query := api.SearchCollectionParams{
  Q:        pointer.String(to),
  QueryBy:  pointer.String("to_id"),
  FilterBy: pointer.String(filter),
  SortBy:   pointer.String("timestamp:desc"),
}

If your alarm bells are going off right now, blaring "red alert," yeah, I hear you. What I did here is a cardinal sin of web development, one of the OWASP Top 10. I allowed an injection attack. It's all because of this line:

filter := fmt.Sprintf("from_id:=%s", from)

See, Typesense doesn't have parameterized queries. Those are standard-issue in SQL and when you use them you're protected from SQL injection attacks. Here, we don't have them, sooooo... If we just carefully craft a handle, that can end up doing fun things from inside our filter query.

I logged in with my totally normal handle, 1||from_id:!=1, and what do you know...

Whoops, now as long as I get my own username into the filter field, I can see anyone else's chats! With that query above, viewing anyone's chats with me actually result in showing me any message which was intended for them. Now we can see Nicole messaging Amy about those work secrets, oh no!

To protect against this, you have a few options. The best solution is to use scoped search keys. These let you essentially pre-filter the dataset with a filter that cannot be modified, so even if someone injects into your filter they can't gain access to data they otherwise can't see. This is a bit more work than parameterized queries would be, though, so I'm a touch sad that this is the solution and I hope parameterized queries land someday!

You could also either ban user input from filter fields or sanitize user input, but both of these are error prone. It's very easy to slip up and allow user input through, and it's really tough to make sure the sanitizer is correct. So it's best not to rely on these and do it with scoped keys!

Searching messages was easy

The star of the show here, really, is searching messages. This was delightfully easy. Here's what it looks like.

We can see that in this search, we're only seeing Nicole's prviate work chat in her own history! And otherwise, we get the results we'd expect.

Typesense helps highlight where the query was found in the search results! I had a small challenge with it, because it's different than what I'm used to. In other libraries I've used, I will get back the indexes of where highlighted text starts and ends. Typesense gives me a convenience here, specifying the start/end tags! The challenge I ran into is how I would make sure that the underlying content is all escaped, to prevent injection attacks, without also escaping these start/end tags! I'm sure there's some way to do that, but I wasn't clear on how.

As far as Taut goes? I'm just yeeting those messages raw into the finished template, as html. I'm also definitely not putting this on the public internet, because y'all can't be trusted like that and someone would 100% immediately do a script injection attack here.

This is what our search query looks like:

filter := fmt.Sprintf("from_id:=%s || to_id:=%s", currentUser, currentUser)
qparams := api.SearchCollectionParams{
  Q:                   pointer.String(query),
  QueryBy:             pointer.String("content"),
  FilterBy:            pointer.String(filter),
  SortBy:              pointer.String("timestamp:desc"),
  HighlightStartTag:   pointer.String("<b>"),
  HighlightEndTag:     pointer.String("</b>"),
  HighlightFullFields: pointer.String("content"),
}

This one has the same vulnerability as before, but with a twist: instead of showing messages to the attacker, it will show the attacker's messages to you. This is delightful when you pair with a 2012 Cabernet script injection attack. Again, you would mitigate this with scoped search keys, but I didn't, so we've got this little delight.

Why shouldn't you use it as a primary datastore?

So, that's Taut. I said at the outset that the docs told me not to do this and I did it anyway. Why do they say not to do it?

There are a few reasons. Some of them were highlighted above, but some are things you'd run into if you kept going with this.

Flexibility of queries is a big one for me. Relational databases have SQL, which is designed for the sort of expressive queries that you do in this sort of app! On the other hand, Typesense is built for search! So the queries are optimized for search scenarios, which are not the same.

Lack of parameterized queries is another one for me. I want my primary datastore to be something that's hardened and really trusted, from both a reliability and a security perspective. Something which doesn't have parameterized queries makes me look twice, from a security perspective. Maybe we shouldn't put user input into filter fields but, okay, someone is going to. We should make that path something that can be reasonably secured. The existing solution of scoped search keys is also a reasonable one, but it's one that's not highlighted in the documentation around filters, so again, someone is going to do this in production.

If you kept adding features to this app, you'd run into lack of transactions. Again, this makes total sense for document search! But for a primary datastore, you often will have multiple things you want to have happen together or not at all. The Credits field I'd included? Originally I wanted to implement a feature that's totally extra, called Extra Chats. If you make a chat "extra", it would send confetti or something. To do this, you'd have to send the message and deduct from a user's credits simultaneously.

You can't insert/update records across two collections, though, and you can't lock rows! There are solutions, like using event sourcing, but... those end up pretty complicated.

And then you also have data durability. Typesense stores everything in memory, so unless it's configured to write to the disk, you can drop data. I was a little annoyed that this is turned on by default, because the wind was taken out of my sails for this point. Turns out, Typesense has worked a lot into making things reliable and durable! Writing data to the disk is enabled by default, and you can enable clustering for high availability as well.

Ultimately, though, it's not designed to be your primary datastore, so you should probably listen to that. There are going to be things that aren't handled perfectly for durability since that's not what they're designing for. So probably don't do this for real.

It seems nice, if you hold it right

I came away from this project hoping that I have a use case sometime soon to use Typesense the right way. There are rough edges, of course, because everthing has them. (Seriously, please add parameterized queries, please please, that seems like a big win for happy path security!)

For actually searching across documents? Oh, it seems really nice! I'd love to use it in that way and get to see it in its environment where it shines.


1

Amusingly, the brief actually stipulates that I'd use it as the primary data store, because I'd pitched that as the idea before the brief was issued and signed. So they did, technically, pay me to use their product wrong!


If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts and support my work, subscribe to the newsletter. There is also an RSS feed.

Want to become a better programmer? Join the Recurse Center!
Want to hire great programmers? Hire via Recurse Center!