Build your own adapter

An adapter is a module implementing the handler surface, so any storage can back Jspreadsheet Server (SQLite, DynamoDB, S3, your existing application database). This page starts with a complete snapshot adapter in ~60 lines, then adds each upgrade toward production quality. Payloads and guarantees are specified in the persistence contract.

Level 1: a complete snapshot adapter

The simplest correct strategy: store each document as one blob and rewrite it on change. It is sufficient for small documents in production, and every upgrade below builds on it. The example uses SQLite (better-sqlite3); the shape is the same for any key-value or relational store:

Complete SQLite example

// adapter.js
const Database = require('better-sqlite3');
const db = new Database('documents.db');

db.exec(`CREATE TABLE IF NOT EXISTS documents (
    guid TEXT PRIMARY KEY,
    user_id TEXT,
    users TEXT DEFAULT '[]',
    spreadsheet TEXT NOT NULL,
    updated INTEGER
)`);

module.exports = {
    // Return the config (object or JSON string), or false for not found
    load: async function(guid, auth, cachedConfiguration) {
        // The server passes the live config when the document is cached;
        // returning it skips the database read entirely
        if (cachedConfiguration) {
            return cachedConfiguration;
        }
        const row = db.prepare('SELECT spreadsheet FROM documents WHERE guid = ?').get(guid);
        return row ? row.spreadsheet : false;
    },

    // Called once per applied operation, in order
    change: async function(guid, changes, auth, onerror) {
        // Snapshot strategy: re-serialize the live document wholesale
        db.prepare('UPDATE documents SET spreadsheet = ?, updated = ? WHERE guid = ?')
            .run(JSON.stringify(changes.instance.getConfig()), Date.now(), guid);
    },

    // Config arrives parsed and validated; creating an existing guid is a no-op
    create: async function(guid, config, auth) {
        db.prepare(`INSERT OR IGNORE INTO documents (guid, user_id, spreadsheet, updated)
                    VALUES (?, ?, ?, ?)`)
            .run(guid, subject(auth), JSON.stringify(config), Date.now());
        return { success: 1 };
    },

    // Full-document overwrite: snapshots restore, error resync
    replace: async function(guid, config, auth) {
        db.prepare('UPDATE documents SET spreadsheet = ?, updated = ? WHERE guid = ?')
            .run(JSON.stringify(config), Date.now(), guid);
    },

    destroy: async function(guid, auth) {
        db.prepare('DELETE FROM documents WHERE guid = ?').run(guid);
    },

    // GET /api/list: the documents owned by the token's subject
    list: async function(auth) {
        return db.prepare('SELECT guid, user_id, updated FROM documents WHERE user_id = ?')
            .all(subject(auth));
    },
};

Wiring it in

Wire it in like an official adapter (Adapters, wiring). The remaining levels improve behavior under load.

Level 2: debounce the snapshot

change fires for every operation; a paste of 10,000 cells is thousands of calls, and re-serializing a large document on each is expensive. Since the server holds the live state in memory, writes can be coalesced per document.

Debounced snapshot example

const pending = new Map();

change: async function(guid, changes, auth, onerror) {
    // Mark dirty; flush at most every 2 seconds per document
    if (!pending.has(guid)) {
        pending.set(guid, setTimeout(() => {
            pending.delete(guid);
            const config = changes.instance.getConfig();
            db.prepare('UPDATE documents SET spreadsheet = ?, updated = ? WHERE guid = ?')
                .run(JSON.stringify(config), Date.now(), guid);
        }, 2000));
    }
},

The trade-off is a crash window of one debounce interval.

Level 3: the failure path

After a failed write, the database no longer matches what connected clients see. The contract provides two tools:

onerror(error)

The server emits forceRefresh to the document's room, so clients reload instead of diverging. Throwing does not work: the server does not await change, so a thrown error or rejected promise is never observed. Catch failures inside the handler and call onerror.

Resync from memory

The live config is authoritative; after a failure, write it wholesale via your replace path instead of retrying the failed incremental updates.

Failure-path example

change: async function(guid, changes, auth, onerror) {
    try {
        await persist(guid, changes);
    } catch (e) {
        try {
            // One full write to resynchronize storage with the live state
            await module.exports.replace(guid, changes.instance.getConfig(), auth);
        } catch (fatal) {
            // Storage is down: force clients to refresh rather than diverge
            onerror(fatal);
        }
    }
},

Level 4: serialize writes per guid

Operations for one document arrive in order but asynchronously; operations for different documents arrive concurrently. With read-modify-write, update N+1 must never read state that predates update N. The official incremental adapters keep a per-guid write queue; a promise chain does the same:

Per-guid promise queue example

const queues = new Map();

const enqueue = function(guid, job) {
    const tail = (queues.get(guid) || Promise.resolve()).then(job, job);
    queues.set(guid, tail);
    return tail;
};

change: async function(guid, changes, auth, onerror) {
    return enqueue(guid, () => persist(guid, changes));
},

Different documents still write with full concurrency; one document's writes are strictly ordered.

Level 5: go incremental

The change payload

For large documents, persist the operation itself instead of re-serializing. Every change call describes what happened:

{
    instance:  spreadsheet,  // live post-apply state (avoid reading it on the hot path)
    worksheet: 'b2a4…',      // target worksheetId
    method:    'setValue',   // the public method applied
    args:      [ ... ],      // its arguments, in order
}

Translate methods to targeted writes

Switch on changes.method and translate to targeted writes: setValue updates exact cell paths, insertRow splices the data array, setStyle touches the style map. A one-cell edit then writes bytes instead of megabytes. Start with the hot methods and fall back to the snapshot write for the rest.

const persist = async function(guid, changes) {
    switch (changes.method) {
        case 'setValue':
            return writeCells(guid, changes.worksheet, changes.args[0]);
        // ... more hot-path methods over time
        default:
            // Fallback: snapshot write for methods without incremental handling
            return module.exports.replace(guid, changes.instance.getConfig());
    }
};

Reuse the official translation layer

If your store can address nested properties (JSONB, document databases, composite keys), reuse the official adapters' translation layer, which turns every spreadsheet method into path-level $set/$unset/$push/$pull descriptors. The PostgreSQL adapter executes those descriptors against a different backend, with full operation coverage and no hand-written method translations.

Level 6: authorization projections

Your authentication hooks run on every load and change and need three fields: the owner, the privacy flag and the invited users. Implement getInfo to return exactly that, not the document body:

Implementing getInfo

getInfo: async function(guid) {
    const row = db.prepare('SELECT user_id, users FROM documents WHERE guid = ?').get(guid);
    if (!row) return null;
    return {
        user_id: row.user_id,
        users: JSON.parse(row.users),
        spreadsheet: { privacy: getPrivacyFlag(guid) },  // a projection, not the body
    };
},

Why projections matter

On multi-megabyte documents, one full-body fetch per operation costs more than a hundred engine applies (measured).

Level 7: extension stores

Extensions look for additional methods on the same adapter: sharing (getUsers/setUsers), the agent's chat sessions (listChats/getChat/setChat/deleteChat) and token accounting (getUsage/setUsage). They are ordinary CRUD keyed by guid and user; add them when you enable the corresponding extension. Full list: adapter surface.

Checklist

  • load semantics: returns false (not null/undefined) for missing documents, and honors cachedConfiguration.
  • Idempotent create: an existing guid is a no-op success.
  • Ordered writes and failure handling: writes for one guid are serialized; a failed write triggers resync-or-onerror, never silence.
  • getInfo projection: cheap and indexed.
  • Big payload test: paste 50k cells, sort a 100k-row worksheet, then monitor write volume and memory.
  • Database failure test: stop the database mid-edit; clients must refresh, and the document must be intact after restart.

What's Next?