Patch Persistence
Keystone-leads persistence: MobX Keystone onPatches translates model mutations into SQL statements. Patches are grouped per top-level action via actionTrackingMiddleware and flushed as a single SQL transaction. Inverse patches enable automatic undo/redo with compensating SQL generated for free.
Patch-to-SQL Translation (Event Sourcing Lite)
Every MobX Keystone mutation produces a patch with structure \{ op: "replace" | "remove" | "add", path: Path, value? \}. The path encodes the model type, entity ID, and changed field β enough information to generate a precise SQL statement.
For a tree like store.todosMap["abc"].title, the patch path is ["todosMap", "abc", "title"], which maps directly to UPDATE todos SET title = ? WHERE id = 'abc'.
Path shapes and their SQL mappings:
["todosMap", "<id>", "<field>"]with opreplace->UPDATE todos SET field = value WHERE id = ?["todosMap", "<id>"]with opadd->INSERT INTO todos ...["todosMap", "<id>"]with opremove->DELETE FROM todos WHERE id = ?
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
ignore-ephemeral-fields | | | β | The patch-to-SQL translator must skip fields that are not persisted: , , computed values, and any field prefixed with (convention for ephemeral/volatile state). |
import { type Patch } from 'mobx-keystone'import { eq } from 'drizzle-orm'
const isEphemeral = (field: string) => field.startsWith('_') || field.startsWith('$')
function patchToSQL(patch: Patch): (() => Promise<void>) | null { const { op, path, value } = patch if (path.length < 2) return null
const [mapName, id, ...fieldPath] = path
switch (mapName) { case 'todosMap': return buildEntityPatch(todos, op, String(id), fieldPath, value) case 'listsMap': return buildEntityPatch(lists, op, String(id), fieldPath, value) default: return null }}
function buildEntityPatch( table: SQLiteTable, op: string, id: string, fieldPath: (string | number)[], value: unknown,) { // add at map level = INSERT if (op === 'add' && fieldPath.length === 0) { return () => db.insert(table).values(value as any) .onConflictDoNothing().then(() => {}) }
// remove at map level = DELETE if (op === 'remove' && fieldPath.length === 0) { return () => db.delete(table) .where(eq(table.id, id)).then(() => {}) }
// replace at field level = UPDATE if (op === 'replace' && fieldPath.length === 1) { const field = String(fieldPath[0]) if (isEphemeral(field)) return null const column = table[field as keyof typeof table] if (!column) return null
return () => db.update(table) .set({ [field]: value } as any) .where(eq(table.id, id)).then(() => {}) }
return null}Action-Level Batching (Unit of Work)
Raw patches fire per-property, not per-action. A single completeTodo() action that sets completedAt and updatedAt produces two patches. These must be grouped and flushed as a single SQL transaction to maintain atomicity across both the in-memory model and the database.
actionTrackingMiddleware intercepts at the top-level action boundary. The filter: ctx.parentContext === undefined predicate ensures only top-level actions trigger persistence β nested @modelAction calls within a domain function do not create separate transactions.
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
top-level-actions-only | | | β | SQL persistence must only trigger for top-level actions. Nested model actions within a domain function must be grouped into the parent's transaction. |
import { actionTrackingMiddleware, patchRecorder } from 'mobx-keystone'
function attachPersistence(store: RootStore) { let recorder = patchRecorder(store, { recording: false })
actionTrackingMiddleware(store, { filter: (ctx) => ctx.parentContext === undefined, // top-level only
onStart: () => { recorder.recording = true },
onFinish: async (ctx, ret) => { recorder.recording = false
if (ret.result === 'return') { // Action succeeded β persist all patches as one SQL transaction const patches = recorder.events.flatMap(e => e.patches) await flushGrouped(patches) } // Action threw β patches already reverted, nothing to persist
recorder.events.splice(0) }, })}Patch Merging (Coalescing)
Multiple field updates to the same entity within one action can be merged into a single UPDATE statement. Group patches by (mapName, id) and coalesce field values into one SET clause.
async function flushGrouped(patches: Patch[]) { const updates = new Map<string, Record<string, unknown>>() const inserts: unknown[] = [] const deletes: string[] = []
for (const { op, path, value } of patches) { const [mapName, id, field] = path if (isEphemeral(String(field))) continue
if (op === 'add' && !field) { inserts.push(value) } else if (op === 'remove' && !field) { deletes.push(String(id)) } else if (op === 'replace' && field) { const key = `${mapName}:${id}` updates.set(key, { ...updates.get(key), [String(field)]: value }) } }
return db.transaction(async (tx) => { for (const [key, fields] of updates) { const [, id] = key.split(':') await tx.update(todos).set(fields).where(eq(todos.id, id)) } for (const data of inserts) { await tx.insert(todos).values(data as any) } for (const id of deletes) { await tx.delete(todos).where(eq(todos.id, id)) } })}
// Result: completeTodo() that sets completedAt + updatedAt produces:// UPDATE todos SET completed_at = ?, updated_at = ? WHERE id = ?// β one statement, not twoUndo/Redo via Inverse Patches (Command)
MobX Keystone generates inverse patches alongside every forward patch. Applying inverse patches reverts the model tree, which fires onPatches again β producing compensating SQL automatically. The full undo/redo stack (including database persistence) falls out of the architecture with no special handling.
The built-in undoMiddleware groups patches per top-level action into UndoEvent objects, each containing forward and inverse patches. Undo means: apply inverse patches (reverts model), which triggers the persistence middleware (reverts DB). Redo means: reapply forward patches.
User calls undo() -> applyPatches(store, inversePatches, true) -> Keystone tree reverts -> onPatches fires with compensating changes -> patch-to-SQL translator runs -> SQL UPDATE/DELETE/INSERT reverting the DB| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
pause-recorder-during-undo | | | β | The patch history recorder must be paused while applying undo/redo patches to prevent recording the undo operation itself as a new history entry. |
import { onPatches, applyPatches, type Patch } from 'mobx-keystone'
type HistoryEntry = { patches: Patch[] inversePatches: Patch[]}
const history: HistoryEntry[] = []const redoStack: HistoryEntry[] = []let isUndoRedoing = false
onPatches(store, (patches, inversePatches) => { if (isUndoRedoing) return // don't record undo ops as history history.push({ patches, inversePatches }) redoStack.length = 0 // clear redo stack on new action})
function undo() { const last = history.pop() if (!last) return redoStack.push(last) isUndoRedoing = true applyPatches(store, last.inversePatches, true) isUndoRedoing = false // persistence middleware fires automatically with compensating SQL}
function redo() { const next = redoStack.pop() if (!next) return history.push(next) isUndoRedoing = true applyPatches(store, next.patches) isUndoRedoing = false}import { undoMiddleware } from 'mobx-keystone'
const undoManager = undoMiddleware(store)
// Each domain function call = one undo step// Regardless of how many fields changed inside applyDraft
undoManager.canUndo // true after first commitundoManager.undo() // reverts entire draft commit atomicallyundoManager.redo() // reapplies itArchitecture
Components
patch-translator
Converts MobX Keystone patches to SQL operations. Maps patch paths to Drizzle table references and builds type-safe insert/update/delete statements. Filters out ephemeral and computed fields.
-
drizzle-schemaβ Resolves table references and column names from Drizzle schema.
persistence-middleware
MobX KeystoneWraps to intercept top-level actions. Records patches during action execution, groups them on completion, and flushes as a single SQL transaction. Skips persistence when actions throw (Keystone already reverts the model tree).
-
patch-translatorβ Delegates patch-to-SQL conversion. -
database-providerβ Executes SQL transactions against the database.
undo-manager
MobX KeystoneManages undo/redo history using Keystoneβs inverse patches. Each history entry corresponds to one top-level action (one draft commit). Applying inverse patches to the model tree automatically triggers the persistence middleware to generate compensating SQL.
-
persistence-middlewareβ Undo/redo mutations flow through the same persistence pipeline.
Rules
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
ignore-ephemeral-fields | | | β | The patch-to-SQL translator must skip fields that are not persisted: , , computed values, and any field prefixed with (convention for ephemeral/volatile state). |
top-level-actions-only | | | β | SQL persistence must only trigger for top-level actions. Nested model actions within a domain function must be grouped into the parent's transaction. |
pause-recorder-during-undo | | | β | The patch history recorder must be paused while applying undo/redo patches to prevent recording the undo operation itself as a new history entry. |
microtask-flush | | | β | Patch flushing should use to run after the current MobX action completes, ensuring all patches from one action are collected before the SQL transaction begins. |