Appearance
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:
| op | fields | what you do |
|---|---|---|
upsert | docType, hash, content (post-render document) | insert-or-replace by hash into your table for docType |
delete | docType, hash | delete the row |
temporalInsert | table, row (incl. synthetic hash, docHash, addedAt, addedBy) | append to the temporal table |
temporalArchive | table, rowHash, key | UPDATE ... 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
- 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. - 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. - The handler must be idempotent-ish: upserts by hash, deletes by hash. Replays after a crash are normal.
- 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.