Skip to content

Practical PouchDB tips (with TypeScript!)

Alex Feyerke edited this page Dec 22, 2022 · 4 revisions

PouchDB has very extensive and useful Typescript typings with excellent in-IDE usage comments that are available as part of the (also excellent) autosuggestions. The documentation for how to use all of this properly is, however, a bit terse. As of December 2022, it consists of:

  1. Seven lines on the offical PouchDB setup page
  2. A Gist from Nolan that he wrote in 2015

That’s not very extensive and missing a bit of nuance. This short guide will get you started quickly and lead to a generally delightful, type-safe PouchDB experience.

Step 1: Define some document types

We’re imagining a todo app, because of course we are. Somewhere in your project, define the various types of documents you have in your PouchDB/CouchDB, for example a todo task:

export type Task = {
	type: "task"
	desc: string
	status: "open" | "completed" | "blocked" | "removed"
}

Note: including _id or any of the other CouchDB-internal keys (_rev, _attachments etc.) isn’t necessary unless you intend to use that key in a non-PouchDB context (as in, not in a method of the PouchDB library). Even then, you can avoid this, we’ll get into how below.

In general, we’re only typing the actual business logic bit of the data, not the CouchDB-internal keys, not the various meta keys you get when you use allDocs etc., none of those things need concern us.

Step 2: Instantiate the PouchDB with the correct types

Nolan’s Gist suggests something like this:

import * as PouchDB from "pouchdb"
import type { Task } from "../types" // or wherever you keep them

const db = new PouchDB<Task>("db")
//                    ^^^^^^ The spicy bit

async function main() {
	const newDoc: PouchDB.Core.Document<Task> = {
		_id: `task:${new Date().toJSON()}`
		type: "task",
		desc: "relax on couch",
		status: "open",
	}
	await db.put(newDoc)
	const doc = await db.get(newDoc._id)
	console.log("doc", doc.desc)
}

A couple of things are happening here:

  1. We’re declaring which document types we’re intending to use in this part of the code as we instantiate the PouchDB with new PouchDB<Task>('db'). You can tell PouchDB you intend to use multiple types with this instance, for example with an in-line union: new PouchDB<Task | Config>('db'). Everything else is then usually inferred by Typescript, with a couple of exceptions (see below)
  2. We cast the newDoc: PouchDB.Core.Document<Task> (you could alternatively add as PouchDB.Core.Document<Task> at the end of the line). This is generally not necessary, but in this example, we’ve defined that for the task documents, type is "task". Typescript currently doesn’t infer that correctly, interpreting our type and status values in the newDoc definition as string types instead of the constants they actually are. You can probably avoid that using TS enums, but it seems enums are falling out of favour, with unions being recommended instead.

In addition, we’re using the PouchDB.Core.Document generic with Task because we want to define an _id for our put(), and our type definition doesn’t include it. In the case of _id specifically, the code would get a lot neater if we did include it in the type (Task is just shorter and more readable than PouchDB.Core.Document<Task>), but in other cases the type definitions would just get too messy (think attachments and conflicts). We’ll get into this again further down.

But, these niggles aside, everything just works: you can start typing doc. and TypeScript should not only autosuggest the business logic keys we’ve defined, but also the internal keys, _id, _rev, _conflicts etc.

Step 3: Nudging TypeScript when it doesn’t know what you know

Being explicit about which keys exist

Documents from PouchDB don’t have a fixed shape, sometimes they include_docs, sometimes they don’t, same for attachments and conflicts. Occasionally, you need to nudge TS in the right direction here. Consider:

const docs = await localDB.allDocs({ include_docs: true })
const firstDesc = docs.rows[0].doc.desc
// ^^^^^^^^^^^^^ Typescript will complain here, since `doc` is optional an only present if `include_docs: true`

Now, we know that include_docs: true, but TypeScript can’t infer that. But we can tell it that we’re sure it exists with a bang !:

const docs = await localDB.allDocs({ include_docs: true })
const firstDesc = docs.rows[0].doc!.desc

Being explicit about which types apply to what you're doing

I mentioned above that you can instantiate a PouchDB with multiple types, or a union type: new PouchDB<Task | Config>('db'). This will require you to be really explicit about which type the doc you’re dealing with actually has, for example:

// We want this bit of code in a changes handler to only operate on Task docs, and some other somewhere maybe on Config docs.
const isTask = (change.doc! as Task).type && (change.doc! as Task).type === "task"
if (isTask) {
        const index = tasks.findIndex((task) => task._id === change.id)
	if (index >= 0) { 
		// Handle updates of tasks we already have in the client
		tasks[index] = change.doc! as Task
	} else {
		// Handle new tasks
		tasks = [change.doc! as Task, ...tasks]
	}
}

Note that you won’t be able to simply check whether type === 'task' like this:

const isTask = changedTask.doc!.type && changedTask.doc!.type === "task"
//                              ^^^^ TypeScript says no!

Because type doesn’t exist in our Config doc type and TypeScript reeeally wants to protect us from ourselves here.

In other words: try to avoid instantiating a PouchDB with multiple types, or a union type, it really only makes your code a lot messier and prone to breaking. It’s actually cleaner to have multiple instances, one per type, or, even better, split out your PouchDB instantiations into separate components that each only deal with one document type.

I need a CouchDB internal key in my data.

Right. You might need to access (or set) _id on your Task, maybe because you have meaningful _ids. Your Task type definition doesn’t include it, and you’re using the doc outside of a PouchDB instance method, so all the meta types (the internal keys like _id) aren’t there.

Solutions:

  1. You could just add the _id: string to your type. That certainly won’t break things.
  2. You could use PouchDB generics to wrap your type: PouchDB.Core.Document<Task>, or PouchDB.Core.ExistingDocument<Task>

There are a lot of type definititons in PouchDB.Core and PouchDB.Replication, but autosuggest is your friend here (at least in VSCode): just type const lol: PouchDB.Core. anywhere and see what options you have. Alternatively, option-click on the Core to jump into the PouchDB type definitions (or go to node_modules/@types/pouchdb-core/index.d.ts in your project). These consist of a lot of extensions and unions and are a bit difficult to read, but you should be able to find your way around after a bit of practise. The types are well-documented and define not only the return types from PouchDB, but also the options for all the methods, so this can double as a practical offline documentation for PouchDB when your IDE autosuggest is for some reason not cutting it.

General PouchDB pitfalls

Mixing up replication events and changes events

Just because I’ve now done this several times: replication/sync events and changes events are different APIs that return differently shaped data. You can kinda use replication events as a changes feed and that will work up to a point, but then you’re very likely Doing it Wrong™. What you probably want is:

// Instantiate db
localDB = new PouchDB<Task>(dbName)
// Start the changes listener
const changesListener = localDB
  .changes({
    include_docs: true,
    live: true,
    selector: { _id: { $regex: "^task:" } },
  })
  .on("change", function (changedTask) { // do things }
// Start the replication
localDB.sync(`http://127.0.0.1:5984/${dbName}/`, {
  live: true,
})

Forgetting to close a replication/changes listener

The PouchDB docs for changes() say Calling cancel() will unsubscribe all event listeners automatically, but this is easy to forget, and therefore it’s easy to cause memory leaks. All you generally need to do is call changesListener.cancel() when your frontend component unmounts. In Svelte, for example, this is super simple:

onMount(async () => {
  // Set up db…
  const changesListener = localDB
    .changes({
      include_docs: true,
      live: true,
      selector: { _id: { $regex: "^task:" } },
    })
    .on("change", function (changedTask) { // do things }
  })
  // whatever you return from `onMount` will be run when the component unmounts.
  // Remember to not actually call this here, so no return changesListener.cancel()
  return changesListener.cancel

How on earth do I get PouchDB to work in Svelte (or possibly other Vite environments)?

If you want to run it in the client, you’ll probably need a dynamic import when you mount your component:

onMount(async () => {
	window.global = window // yes really
	const PouchDB = await (await import("pouchdb")).default // sorry
	localDB = new PouchDB<Task>(dbName) // from here on no more weirdness
	// etc etc.
})

Deleted documents and selector

It sounds amazing: pre-filter your changes feeds (or replications) by providing a selector to the method options, and only listen for exactly the changes you want. This almost works, too, but you’ll find you might be missing all deletes, for example if you’re deleting a doc in Fauxton and that deletion gets synced to your local PouchDB:

localDB = new PouchDB<Task>(dbName)
localDB
	.changes({ include_docs: true, live: true, selector: { type: "task" } })
	.on("change", function (change) {
		// The doc in `change` must be a `Task`, yay!
		// But ugh, all document delete changes are missing :|
	})

Why is this? Because changes that are deletes don’t include the doc (after all, you’ve deleted it!), they look like this:

{
	"id": "task:ab83f18fe9d14a6e9186b2901e02bb2a",
	"changes": [
		{
			"rev": "2-24b2f69377f47dcc796053a2854e48f1"
		}
	],
	"doc": {
		"_deleted": true,
		"_id": "task:ab83f18fe9d14a6e9186b2901e02bb2a",
		"_rev": "2-24b2f69377f47dcc796053a2854e48f1"
	},
	"deleted": true,
	"seq": 50
}

So of course you can’t select a matching type key, it doesn’t exist.

Solutions:

  1. Don’t actually delete the document, but set _deleted: true on it. This will leave the rest of the document intact and still register as a document deletion.
  2. Have meaningful document _ids that include the doc type and select against that, for example:
localDB
  .changes({ include_docs: true, live: true, selector: { _id: { $regex: "^task:" } })