Build your own adapter

Any storage can back Jspreadsheet Server (SQLite, DynamoDB, S3, your existing application database) because an adapter is a module implementing the handler surface. This page builds one from scratch: first a complete, correct snapshot adapter in ~60 lines, then each upgrade that takes it to production quality. The exact payloads and guarantees behind every handler are specified in the persistence contract.

Level 1: a complete snapshot adapter

The simplest correct strategy: store each document as one blob, rewrite it on change. This is sufficient for small documents in production, and every upgrade below is incremental on top of it. The example uses SQLite (better-sqlite3), but the shape is identical 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 exactly like an official adapter (Adapters, wiring) to get working persistence. The remaining levels improve behavior under load.

Level 2: debounce the snapshot

change fires for every operation, and a paste of 10,000 cells is thousands of calls. Re-serializing a large document on each call is expensive. Because the server always 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));
    }
},

Trade-off: the crash window

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

Level 3: the failure path

When a write fails, do not continue: the database no longer matches what connected clients see. The contract provides two tools:

onerror(error)

Calling it makes the server emit forceRefresh to the document's room, so clients reload instead of diverging. Throwing is not a substitute: the server invokes change without awaiting its result, 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. If your write path does read-modify-write, update N+1 must never read state that predates update N. Both official incremental adapters keep a per-guid write queue; a promise chain achieves 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, stop re-serializing and persist the operation itself. Every change call carries the full description of 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. A setValue updates exact cell paths, an insertRow splices the data array, a setStyle touches the style map. A one-cell edit then writes bytes instead of megabytes. Full coverage is not required initially: handle 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), the official adapters share a translation layer that turns every spreadsheet method into path-level $set/$unset/$push/$pull descriptors, and the PostgreSQL adapter is a complete worked example of executing 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 every change, and they need three pieces of information: the owner, the privacy flag, the invited users. Implement getInfo to return exactly that, and 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

This is a performance requirement: on multi-megabyte documents, one full-body fetch per operation costs more than a hundred engine applies (measured).

Level 7: extension stores

If you use the extensions, they 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. The full list is in the adapter surface.

Checklist

load semantics

load returns false (not null/undefined) for missing documents, and honors cachedConfiguration.

Idempotent create

create on 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

getInfo is a cheap, indexed projection.

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?