-
Notifications
You must be signed in to change notification settings - Fork 0
Practical PouchDB tips (with TypeScript!)
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:
- Seven lines on the offical PouchDB setup page
- 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.
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.
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:
- 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) - We cast the
newDoc: PouchDB.Core.Document<Task>
(you could alternatively addas PouchDB.Core.Document<Task>
at the end of the line). This is generally not necessary, but in this example, we’ve defined that for thetask
documents,type
is"task"
. Typescript currently doesn’t infer that correctly, interpreting ourtype
andstatus
values in thenewDoc
definition asstring
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.
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
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.
Right. You might need to access (or set) _id
on your Task
, maybe because you have meaningful _id
s. 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:
- You could just add the
_id: string
to your type. That certainly won’t break things. - You could use PouchDB generics to wrap your type:
PouchDB.Core.Document<Task>
, orPouchDB.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.
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,
})
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
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.
})
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:
- 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. - Have meaningful document
_id
s that include the doc type and select against that, for example:
localDB
.changes({ include_docs: true, live: true, selector: { _id: { $regex: "^task:" } })