Skip to content
Patch Persistence
AutoXXS (320px)XS (375px)SM (640px)MD (768px)LG (1024px)XL (1280px)XXL (1536px)
SketchMaterialiOSTamagui
DataInjectionKeyPatternsServiceTransactionProcessResearchProductQualityPerformanceSpecDomainFunctionTechnologyArchitectureConfigMiddlewareDataDatabaseDrizzleMigrationModelop-sqliteSchemaSQLState ManagementDraftKeystoneMergePatchPatchesPersistenceReactiveRedoStoreUndoTestingDeviceFactoryIsolationTypeScriptZodTopicsCommunicationBidsNVCDesignDesign ImplicationsEducationPedagogyFoundationsPsychologyAttachmentFloodingRelatingAuthentic RelatingUIEditorReact Native

Patch Persistence

module

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 op replace -> UPDATE todos SET field = value WHERE id = ?
  • ["todosMap", "<id>"] with op add -> INSERT INTO todos ...
  • ["todosMap", "<id>"] with op remove -> DELETE FROM todos WHERE id = ?
Rule Force Realm Reference Description
ignore-ephemeral-fields must global β€” The patch-to-SQL translator must skip fields that are not persisted: , , computed values, and any field prefixed with (convention for ephemeral/volatile state).
Patch-to-SQL translator function
PatchSQLDrizzle patch-translator
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 must global β€” SQL persistence must only trigger for top-level actions. Nested model actions within a domain function must be grouped into the parent's transaction.
Action tracking middleware for grouped SQL persistence
MiddlewarePersistenceTransaction action-batching-middleware
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.

Merged patch flush with grouped UPDATE statements
PatchMergeSQL grouped-flush
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 two

Undo/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 must global β€” The patch history recorder must be paused while applying undo/redo patches to prevent recording the undo operation itself as a new history entry.
Undo/redo with inverse patches and automatic SQL rollback
UndoRedoPatches undo-redo-wiring
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
}
Using Keystone's built-in undoMiddleware
UndoMiddleware builtin-undo-manager
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 commit
undoManager.undo() // reverts entire draft commit atomically
undoManager.redo() // reapplies it

Architecture

architecture Patch Persistence architecture
Gallery

Components

adapter

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.

Collaborates with
  • drizzle-schema β€” Resolves table references and column names from Drizzle schema.
infrastructure

persistence-middleware

MobX Keystone

Wraps 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).

Collaborates with
  • patch-translator β€” Delegates patch-to-SQL conversion.
  • database-provider β€” Executes SQL transactions against the database.
service

undo-manager

MobX Keystone

Manages 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.

Collaborates with
  • persistence-middleware β€” Undo/redo mutations flow through the same persistence pipeline.

Rules

Rule Force Realm Reference Description
ignore-ephemeral-fields must global β€” 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 must global β€” 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 must global β€” 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 should global β€” 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.