Skip to content

Rendering — your database, your reads

Document Store separates writes from reads. Writes go through the store (hash, validate, sign, log). Reads never touch the store — they query a database you own. The bridge between the two is the render handler: a function the store calls with a batch of operations on every accepted write, local or synced from a peer, so your database always mirrors the latest document state.

Writes:  App → DocumentStore → [hash, validate, sign, log] → renderHandler → YOUR DB
Reads:   App → YOUR DB (direct — SQL, indexes, joins, whatever you know)

This replaced the older "persistence backend" classes: instead of the store owning a SQLite/SurrealDB/MongoDB adapter for rendered state, the store owns only its canonical log (always SQLite, the storage: path) and hands rendering to you. Two consequences worth spelling out:

  • Any database works. If you know your DB, you can write a render handler for it in an afternoon — the contract below is four operation types. Postgres, DuckDB, Redis, an in-memory map for tests: all fine.
  • Any language works. The store core is Rust; the render-handler boundary is how every binding (Node today; other FFI lanes the same way) plugs rendering into its own ecosystem without the core knowing anything about your database.

The contract: RenderOp

Your handler receives RenderOp[] batches:

opfieldswhat you do
upsertdocType, hash, content (post-render document)insert-or-replace by hash into your table for docType
deletedocType, hashdelete the row
temporalInserttable, row (incl. synthetic hash, docHash, addedAt, addedBy)append to the temporal table
temporalArchivetable, rowHash, keyUPDATE ... SET removedAt/removedBy WHERE hash = rowHash

(The temporal ops only fire for types with temporal fields configured; simple apps never see them.)

Default handlers — start here

You don't have to write a handler to get going:

typescript
import { createStore } from 'document-store';
import Database from 'better-sqlite3';

const db = new Database('./data/rendered.db');
db.exec(`CREATE TABLE IF NOT EXISTS note (
    hash BLOB PRIMARY KEY, title TEXT, body TEXT, created INTEGER
)`);

const store = await createStore({
    storage: './data/ds.sqlite',          // the store's canonical log
    renderHandler: async (ops) => {
        for (const op of ops) {
            if (op.docType !== 'note') continue;
            if (op.type === 'upsert') {
                const c = op.content as any;
                db.prepare(`INSERT OR REPLACE INTO note
                    (hash, title, body, created) VALUES (?, ?, ?, ?)`)
                  .run(op.hash, c.title, c.body, c.createdAt);
            } else if (op.type === 'delete') {
                db.prepare('DELETE FROM note WHERE hash = ?').run(op.hash);
            }
        }
    },
});
typescript
import { createStore, createSurrealRenderHandler } from 'document-store';
import { Surreal } from '@surrealdb/node';

const db = new Surreal();
await db.connect('surrealkv://./data/rendered');
await db.use({ namespace: 'app', database: 'app' });

const store = await createStore({
    storage: './data/ds.sqlite',
    // One table per document type, TS-DS-compatible layout,
    // temporal tables included.
    renderHandler: createSurrealRenderHandler(db),
});

In the SQLite example you design the table — project document fields into real columns and your reads are plain indexed SQL. That fifteen-line handler is the recommended pattern for most apps (it's what the first-app tutorial and the notes example use).

The Rust lane has its own default: wish-app-base's open_default_rendered_sqlite gives Tauri apps a ready <type>(hash, content) layout — same contract, same rules.

Rules that keep you out of trouble

  1. Two databases, never one. The store's canonical log (ds.sqlite) and your rendered DB are separate files with separate connections. Don't point them at the same place.
  2. Rendered tables are write-only for the handler, read-only for you. Never write app data into rendered tables directly — it won't be hashed, signed, or synced, and the next render will silently fight you. All writes go through store.add/edit/save.
  3. The handler must be idempotent-ish: upserts by hash, deletes by hash. Replays after a crash are normal.
  4. Keep it fast and local. The handler runs on the write path; a prepared statement per op is the right weight. Network hops in a render handler will make every write pay for them.

Custom handlers for your database

The handler is just a function — there is no class to extend. To render into Postgres (or anything else): translate the four ops into your DB's upsert/delete/append/update, decide your table layout (one table per type is conventional), and you're done. If your handler is generally useful, consider contributing it like createSurrealRenderHandler.