Draft Store
A generic, persistent draft mechanism for in-flight edits. Drafts are ephemeral UI state keyed by type and domain entity ID, persisted as JSON blobs for crash recovery. The draft commit is the action boundary — domain functions receive the target model as first argument and the draft as data payload, producing one undo entry and one SQL transaction per save.
Draft State Is UI State
Draft state does not belong in SQLite domain tables or in persisted domain models. It is transient editor state with a different lifecycle: created when the user begins editing, mutated freely with reactive bindings on every keystroke, and discarded or committed when the user saves. Keeping it separate makes both layers cleaner — persisted models stay as faithful DB projections, and drafts can carry editor-specific validation, field coercion, and computed helpers without polluting the domain model.
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
no-domain-persistence-for-drafts | | | — | Draft models must never write directly to domain SQLite tables. Drafts are committed through domain functions which validate and persist via the service or action layer. |
Type/ID Key Encoding
Every draft is keyed by a composite string encoding the domain type and entity ID it maps to. This enables a single generic DraftStore to manage drafts across all entity types without per-type stores.
Key format: \{type\}:\{id\} for editing existing entities, \{type\}:new:\{uuid\} for creating new entities.
Draft Schema Derivation
Draft Zod schemas are the one place hand-written Zod is acceptable (since drafts are not database tables). However, they should derive from the domain insert schema to avoid structural drift. When a column is added to the Drizzle table, the insert schema updates, and the draft schema inherits the change automatically unless explicitly omitted.
export const TodoDraftSchema = TodoInsertSchema .omit({ id: true, createdAt: true }) // generated fields .extend({ refId: z.string().nullable(), // draft-specific metadata }) .partial() // all fields optional while editing .extend({ title: z.string().default(''), // override: empty string, not missing })
const TodoDraft = modelFromZod('TodoDraft', TodoDraftSchema)Commit as Action Boundary
The draft commit is the granularity boundary for undo history and SQL persistence. Individual keystrokes mutate the draft freely (no patches recorded against the domain model, no SQL). Only when the user saves does a domain function validate the draft and apply it to the domain model in a single @modelAction. This produces one patch group, one undo entry, and one SQL transaction.
User edits fields -> draft.title = "new title" (no patches recorded, no SQL) -> draft.completedAt = null (no patches recorded, no SQL)
User presses Save -> commitTodoEdit(todo, draft) <- domain logic + validation -> todo.applyDraft(draft) <- @modelAction, patches recorded patch: replace title patch: replace completedAt patch: replace updatedAt
actionTrackingMiddleware.onFinish fires -> all patches grouped -> one SQL transaction
undoManager records one UndoEvent -> undo() reverts entire draft commit atomicallyDomain Functions (Command)
Domain logic is expressed as plain functions (not model methods) that always receive the target domain object as first argument and the draft (or other arguments) as subsequent parameters. This keeps models thin (data + mutation primitives only), domain logic pure and testable, and decouples validation from the model class.
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
first-arg-is-target | | | — | Domain functions should receive the domain model or store as their first argument and the draft or data payload as subsequent arguments. This convention enables consistent composition and testing. |
// Domain functions — pure, no class needed// First argument is always the domain object (or store for creation)
function commitTodoEdit(todo: Todo, draft: TodoDraft): void { if (!draft.isValid) throw new Error('Title required') if (todo.completedAt && !draft.completedAt) { throw new Error('Cannot un-complete a todo') } // single @modelAction — one patch group, one undo entry, one SQL transaction todo.applyDraft(draft)}
function commitTodoCreate(store: TodoStore, draft: TodoDraft): void { if (!draft.isValid) throw new Error('Title required') store.addTodo(Todo.fromDraft(draft))}
// Namespace for discoverabilityconst TodoDomain = { edit: (todo: Todo, draft: TodoDraft) => { /* ... */ }, complete: (todo: Todo) => { /* ... */ }, block: (todo: Todo, blockedBy: Todo) => { /* ... */ },}
const TodoStoreDomain = { create: (store: TodoStore, draft: TodoDraft) => { /* ... */ }, reorder: (store: TodoStore, ids: string[]) => { /* ... */ },}Architecture
Components
draft-model
MobX KeystoneA generic model with type/id key encoding, arbitrary data payload, and timestamps. Carries metadata about whether it represents a new entity or an edit of an existing one. Provides computed helpers for validation and change detection.
@model('Draft')class Draft extends Model({ key: prop<string>(), // "todo:123" | "todo:new:abc" type: prop<string>(), // "todo" refId: prop<string | null>(null), // null = new entity data: prop<Record<string, unknown>>(), createdAt: prop<number>(), updatedAt: prop<number>(),}) { @computed get isNew() { return this.refId === null }}draft-store
MobX KeystoneManages the lifecycle of all active drafts. Provides upsert, get, and discard operations. Persists the entire draft collection as a JSON blob in a SQLite key-value table with debounced writes. Supports hydration on app restart for crash recovery.
-
draft-model— Holds a collection of Draft model instances.
@model('DraftStore')class DraftStore extends Model({ drafts: prop<Draft[]>(() => []),}) { @modelAction upsertDraft(key: string, data: Record<string, unknown>) { const existing = this.drafts.find(d => d.key === key) if (existing) { existing.data = data existing.updatedAt = Date.now() } else { const [type, maybeNew, ...rest] = key.split(':') this.drafts.push(new Draft({ key, type, refId: maybeNew === 'new' ? null : maybeNew, data, createdAt: Date.now(), updatedAt: Date.now(), })) } this.persist() }
getDraft(key: string) { return this.drafts.find(d => d.key === key) ?? null }
@modelAction discardDraft(key: string) { this.drafts = this.drafts.filter(d => d.key !== key) this.persist() }
@computed get pendingDrafts() { return this.drafts.filter(d => d.isNew) }
@computed get unsavedEdits() { return this.drafts.filter(d => !d.isNew) }
persist = debounce(() => { db.execute( 'INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)', ['drafts', JSON.stringify(getSnapshot(this))] ) }, 500)
static async load(): Promise<DraftStore> { const row = await db.execute('SELECT value FROM kv WHERE key = ?', ['drafts']) const snapshot = row.rows[0]?.value return snapshot ? fromSnapshot<DraftStore>(JSON.parse(snapshot)) : new DraftStore({}) }}draft-aware-model
MobX KeystoneDomain models that participate in draft-based editing expose a single model action as their write surface. This action is the only mutation point called by domain functions, ensuring all field changes within one save produce a single patch group.
@model('Todo')class Todo extends Model({ id: prop<string>(), title: prop<string>(), completedAt: prop<number | null>(null), updatedAt: prop<number>(),}) { @computed get isCompleted() { return this.completedAt !== null }
// Single write surface — called by domain functions @modelAction applyDraft(draft: TodoDraft) { this.title = draft.title this.completedAt = draft.completedAt this.updatedAt = Date.now() }
static fromDraft(draft: TodoDraft): Todo { return new Todo({ id: generateId(), title: draft.title, completedAt: draft.completedAt, updatedAt: Date.now(), }) }}editor-binding
React NativeEditor components bind to the draft model (not the persisted domain model). The draft is created from the domain entity on mount, mutated freely via two-way bindings, and committed via the domain function on save. Draft lifetime is managed via component state, a volatile on a screen model, or the global DraftStore for crash recovery.
-
draft-store— Retrieves or creates drafts by key. -
draft-aware-model— Commits drafts via domain functions targeting the domain model.
const EditTodoScreen = observer(({ todoId }) => { const todo = store.getTodo(todoId) const draftKey = `todo:${todoId}`
// Restore or create draft const [draft] = useState(() => draftStore.getDraft(draftKey) ?? draftStore.createDraft(draftKey, TodoDraft.fromTodo(todo)) )
return ( <> <TextInput value={draft.title} onChangeText={action(v => draft.title = v)} /> <Button disabled={!draft.isValid} onPress={() => { commitTodoEdit(todo, draft) draftStore.discardDraft(draftKey) }} /> </> )})Rules
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
no-domain-persistence-for-drafts | | | — | Draft models must never write directly to domain SQLite tables. Drafts are committed through domain functions which validate and persist via the service or action layer. |
first-arg-is-target | | | — | Domain functions should receive the domain model or store as their first argument and the draft or data payload as subsequent arguments. This convention enables consistent composition and testing. |
draft-recovery | | | — | Drafts representing new entity creation should be persisted to the DraftStore for crash recovery. Drafts for editing existing entities may use component-local state if recovery is not critical. |
discard-on-commit | | | — | Drafts must be discarded from the DraftStore after successful commit to prevent stale drafts from resuracing on next app launch. |