Persistence contract

What your storage handlers receive, what they must return, and what the server does when they fail. The official MongoDB, PostgreSQL and Redis adapters implement this contract; any storage that follows it works.

The operation object

Your change handler receives one object per applied operation:

{
    instance:  spreadsheet,   // the live server-side spreadsheet (post-apply state)
    worksheet: 'b2a4…',       // worksheetId the operation targeted
    method:    'setValue',    // the public method that was applied
    args:      [ ... ],       // the method's arguments, as an array
}

Three properties of this design:

Called after the engine applied the operation

instance already reflects the new state; args describes the change itself. Persist from args (incremental) whenever possible: serializing instance on every change is expensive and does not scale.

Arguments are the public method's parameters, in order

A setValue carries an array of { x, y, value } records; an insertRow carries its row descriptors; and so on. The wire protocol, the engine, and persistence use the same method vocabulary, so there is no separate diff format.

Cascaded effects are not delivered

Formula recalculations, spill updates and other derived state are not sent as separate operations; every consumer recomputes them by replaying the same op. If your storage needs computed values, read them from instance (see the caveat above).

Ordering

Per-document apply order

Operations for one document are delivered in apply order, the same order the revision counter records.

Concurrency across documents

Operations for different documents arrive concurrently. If your storage layer cannot tolerate concurrent writes within one document (most cannot, for read-modify-write updates), serialize per guid. The MongoDB and PostgreSQL adapters keep an internal per-guid queue for exactly this.

Failure semantics

change(guid, changes, auth, onerror):

Signal failure with onerror

On persistence failure, call onerror(error). The server then emits forceRefresh to the document's room, so every client reloads instead of continuing on state your database never recorded. Throwing does not work: the server does not await change, so a thrown error or rejected promise is never observed. The official adapters catch their own errors and call onerror.

Recovery by full-write resynchronization

On a failed incremental update, the MongoDB and PostgreSQL adapters drain the pending per-guid queue (remaining operations resolve without being written) and write instance.getConfig() wholesale via replace: one full write resynchronizes storage with the live state, instead of a divergent tail of incremental updates.

load(guid, auth, cachedConfiguration)

Return value

Return the full document configuration (object or JSON string), or false for "not found".

The cached-configuration shortcut

When the document is already live in the cache, the server passes its current config as cachedConfiguration; return it directly to skip the database.

When load runs

REST and MCP requests against a warm cache never reach load: the server answers from the live instance. Realtime load requests always call it, with cachedConfiguration set when the document is live, so a warm load needs no database read.

create(guid, config, auth)

Pre-validated configuration

The config arrives parsed and pre-validated: every worksheet has a worksheetName (rejected otherwise) and a minted worksheetId; document-level namespace and version are set. Persist it as the document body.

Idempotent creation

Creating an already-existing guid should be a no-op success. The MongoDB and PostgreSQL adapters return { success: 1 } without touching storage; the Redis adapter returns the guid and only writes when the key does not exist.

replace(guid, config, auth)

Full-document overwrite

replace writes the given configuration wholesale over the stored document. The config may arrive as an object or a JSON string; the official adapters parse strings before writing. The snapshot-restore route calls it (without a replace handler, the server restores with destroy + create), and the incremental adapters call their own replace for failure resynchronization.

destroy(guid, auth)

Deletion side effects

Remove the stored document. When the handler returns truthy, the server drops the document from its cache, emits forceRefresh to the room and disconnects every socket; a falsy return is the only way to veto the deletion.

Storage sizing rules

MongoDB: the 16 MB document ceiling

One document per spreadsheet is subject to the 16 MB BSON ceiling. A million-cell spreadsheet serializes to roughly 5 MB, which fits; twenty times that does not. Keep images out of the document (store on S3), and let the adapter's incremental updates carry the write load.

Redis: full-document writes per change

The ready-made adapter re-serializes the live document on every change and stores it as one JSON string under the document's guid: correct for small documents, unsuitable beyond a few MB. For large documents prefer a debounced full-write pattern: mark dirty on change and flush the serialized document on a timer.

No full-document reads for per-operation lookups

Answer per-operation lookups, above all the authorization hooks that run on every load and change, with field projections, never full-document fetches: getInfo reads the owner, the privacy flag and the invited users, and nothing else. On multi-megabyte documents, one full body fetch per operation costs more than the operation itself (measured). The incremental adapters' write path does read the stored document once per operation, but only inside the per-guid queue, as the basis for computing the targeted update, not once per authorization check.