@mikehall314/cushion - JSR

JSR

4 min read Original article ↗

A PouchDB storage adapter backed by Deno KV.

Cushion implements the PouchDB adapter interface directly against Deno KV. It gets you a real PouchDB: rev trees, conflict resolution, map/reduce views, and replication, backed by Deno's built-in database.

Quick start

import { PouchDB } from "jsr:@mikehall314/cushion";

const db = new PouchDB("my-app", { adapter: "cushion" });

await db.put({ _id: "hello", msg: "world" });
const doc = await db.get("hello");

Documents

Revisions are tracked automatically, giving you optimistic concurrency for free: if two writers update the same document concurrently, the loser gets a 409 and should re-read and retry.

async function increment(id: string) {
    const doc = await db.get<{ count: number }>(id);
    try {
      return await db.put({ ...doc, count: doc.count + 1 });
    } catch (err) {
      // someone else wrote first -- reread and retry
    }
  }
}

Views

Two helpers to make the map-reduce API a little more ergonomic to work with.

  • withEmit helps making a map function with type safety
  • ViewQuery is a query builder for db.query()
import { PouchDB, ViewQuery, withEmit } from "jsr:@mikehall314/cushion";

type Product = { _id: string; category: string; price: number; qty: number };

const db = new PouchDB("shop", { adapter: "cushion" });

await db.put({
  _id: "_design/stock",
  views: {
    "value-by-category": {
      map: withEmit<Product, number>((emit) => (doc) => {
        emit(doc.category, doc.price * doc.qty);
      }),
      reduce: "_sum",
    },
  },
});

// Total stock value per category
const query = ViewQuery.for("stock", "value-by-category").group(true);
const result = await db.query(...query.getParams());
for (const row of result.rows) {
  console.log(row.key, row.value);
}

ViewQuery covers the usual PouchDB query shapes:

ViewQuery.for("stock", "value-by-category"); // full scan
ViewQuery.for("stock", "value-by-category").key("produce"); // single key
ViewQuery.for("stock", "value-by-category").keys(["produce", "dairy"]); // specific keys
ViewQuery.for("stock", "value-by-category").range("bakery", "dairy", ViewQuery.INCLUDE_END); // key range
ViewQuery.for("stock", "value-by-category").order(ViewQuery.DESCENDING); // sort order
ViewQuery.for("stock", "value-by-category").limit(10).skip(20); // pagination
ViewQuery.for("stock", "value-by-category").includeDocs(); // inline full documents
ViewQuery.for("stock", "value-by-category").reduce().group(true); // reduce, grouped by full key
ViewQuery.for("stock", "value-by-category").reduce().group(1); // reduce, grouped by first key part

getParams() returns a [viewName, params] tuple matching db.query()'s signature, so use spread: db.query(...query.getParams()) or if you really want to you can use const [viewName, params] = query.getParams();

Custom configuration

The default PouchDB export is bound to Deno's default KV, opened lazily. For a specific Deno.Kv instance or file path (for tests, multiple independent stores, an in-memory database, whatever) build can your own chain with init() instead:

import PouchDBCore from "npm:pouchdb-core";
import mapreduce from "npm:pouchdb-mapreduce";
import replication from "npm:pouchdb-replication";
import { init } from "jsr:@mikehall314/cushion/plugin";

// Use an existing Deno.Kv instance
const kv = await Deno.openKv(":memory:");
const PouchDB = PouchDBCore
  .plugin(mapreduce)
  .plugin(replication)
  .plugin(init({ kv }));

const db = new PouchDB("mydb", { adapter: "cushion" });

init() only registers the adapter -- add mapreduce/replication yourself if you want views or sync, same as any other PouchDB plugin.

Limits and caveats

Deno KV imposes a 64 KiB limit per value, so a single document revision (or attachment) larger than that will fail to write. Deno KV atomic operations are also capped at 1000 mutations and roughly 800 KiB total; cushion chunks large bulkDocs batches across multiple atomic commits, which means a very large batch is not atomic as a whole. Batches under ~100 document writes commit in one atomic operation.

Writes within a process are serialised, and cross-process writers are handled with an optimistic versionstamp check and retry (3 attempts). Under sustained contention from many processes a write can fail with a 409 after retries are exhausted.

Compaction removes old revision bodies but does not garbage-collect attachment data, since attachments are stored by digest and may be shared across revisions. db.close() does not close the underlying KV handle because it may be shared by other databases; close the handle yourself if you opened it, or let the process exit.

Development

deno task test

License

MIT