Persistence contract
This page specifies what your storage handlers receive, what they must return, and what the server does when they fail. It is the contract the official MongoDB, PostgreSQL and Redis adapters implement; 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, because serializing instance on every change is expensive and does not scale under load.
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, because 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 rather than continuing on state your database never recorded. Throwing is not a substitute: the server invokes change without awaiting its result, 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
The recovery pattern the MongoDB and PostgreSQL adapters use: on a failed incremental update, drain the pending per-guid queue (remaining operations are resolved without being written) and write instance.getConfig() wholesale via replace, one full write to resynchronize 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; you may return it directly and skip your database entirely.
When load runs
REST and MCP requests against a warm cache never reach load, because the server answers from the live instance and returns before the handler is called. Realtime load requests always call it, but with cachedConfiguration set when the document is live, so a warm load can complete without a 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, and the official adapters parse strings before writing. It is called by the snapshot-restore route (when no replace handler exists, the server restores with destroy + create instead), 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, so 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.