The data model
One idea underpins persistence, sync, and undo: every change to the document is a diff, and the diff is plain JSON.
Records
A document is a flat map of immutable records — strokes, shapes, notes,
text, images, and their assets. store.get(id) returns a record;
mutating never edits a record in place, it replaces it.
store.get(id) // one record
store.shapes() // all drawable records
store.ids() // every id
store.size // record countDiffs
Every mutation happens in a transaction, and every transaction emits:
{
added: { [id]: record },
removed: { [id]: record },
updated: { [id]: [from, to] }
}Three properties make this shape do all the work:
- JSON-safe. Records are plain objects; a diff survives
JSON.stringifyuntouched. Any transport, any database. - Invertible.
invertDiff(diff)swaps added/removed and flips[from, to]— that’s undo. - Composable.
composeDiff(a, b)merges consecutive diffs — that’s how a multi-event gesture becomes a single undo entry.
Both helpers are exported from @quickdrawjs/core.
Listening
const stop = store.listen((diff, source) => { ... })
const stopUser = store.listen(fn, { source: 'user' }) // only local changesSources
Every change carries a source: 'user' for local edits, 'remote' for
changes applied from outside (a peer, a restore). The store keeps
'remote' changes out of the local undo stack — the foundation of
correct multiplayer undo.
store.applyDiff(diff, 'remote')
store.loadSnapshot(snap, 'remote')Writing
store.put(record) // add or replace
store.update(id, { x: 100 }) // shallow patch
store.remove([id1, id2])
store.transact(() => { // many writes, one diff, one undo entry
store.put(a)
store.update(b.id, { w: 200 })
})History
store.undo(); store.redo()
store.canUndo; store.canRedo
store.listenHistory(fn) // can-undo/can-redo changedUndo entries are diffs, composed per gesture — a freehand stroke of two hundred pointer events is one entry.
Snapshots
const snap = store.getSnapshot() // the whole document, plain JSON
store.loadSnapshot(snap, 'remote')Snapshots and diffs are the same records, whole — there is exactly one serialization story in the engine. Log the diff stream and you can replay a board stroke by stroke; snapshot and reload and you’re back where you were. See Persistence and Real-time sync for the applied versions.