Data Architecture
Reactive data pipeline for React Native: Drizzle ORM defines SQLite tables as the single schema source, Zod schemas are derived automatically, and MobX Keystone models are generated from Zod via a factory. Two data-flow modes coexist: SQLite-leads for read-heavy domain state (Option A) and Keystone-leads for UI-driven state (Option B).
Single Schema Source (Schema-First)
The Drizzle sqliteTable(...) definition is the sole authoritative schema. All other representations — Zod validation schemas, TypeScript types, and MobX Keystone models — are derived from it. Adding a column to the Drizzle table propagates through the entire stack without manual synchronisation.
sqliteTable(...) <- one definition |-- Drizzle queries <- type-safe SQL, autocomplete on columns |-- $inferSelect / Insert <- raw TS types |-- createSelectSchema() <- Zod for query results + API responses |-- createInsertSchema() <- Zod for writes + service layer input |-- createUpdateSchema() <- Zod for partial updates +-- modelFromZod() <- Keystone model for UI reactivity +-- .extend() <- computed values, domain behavior| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
drizzle-is-source | | | — | Schema definitions must originate in Drizzle table declarations. Hand-written Zod schemas are only acceptable for non-persisted state (drafts, UI-only models). |
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'import { createSelectSchema, createInsertSchema, createUpdateSchema } from 'drizzle-orm/zod'
// 1. Single source of truthexport const todos = sqliteTable('todos', { id: text('id').primaryKey(), title: text('title').notNull(), description: text('description'), completedAt: integer('completed_at'), listId: text('list_id').notNull(), createdAt: integer('created_at').notNull(),})
// 2. Zod schemas derived for freeexport const TodoSelectSchema = createSelectSchema(todos, { title: (s) => s.min(1).max(200),})export const TodoInsertSchema = createInsertSchema(todos)export const TodoUpdateSchema = createUpdateSchema(todos)
// 3. TS types flow through automaticallyexport type TodoRow = typeof todos.$inferSelectexport type TodoInsert = typeof todos.$inferInsertimport { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'import { createSelectSchema, createInsertSchema, createUpdateSchema } from 'drizzle-orm/zod'
// 2. Zod schemas derived for freeexport const TodoSelectSchema = createSelectSchema(todos, { title: (s) => s.min(1).max(200),})export const TodoInsertSchema = createInsertSchema(todos)export const TodoUpdateSchema = createUpdateSchema(todos)
// 3. TS types flow through automaticallyexport type TodoRow = typeof todos.$inferSelectexport type TodoInsert = typeof todos.$inferInsertZod-to-Keystone Mapping (Factory)
A modelFromZod factory converts a z.ZodObject into a MobX Keystone Model class. It maps Zod primitives (ZodString, ZodNumber, ZodBoolean, ZodOptional, ZodNullable, ZodArray, ZodDefault) to Keystone tProp types. The generated class includes a static validate() that runs the Zod schema and a fromRaw() safe constructor.
For domain behaviour (computed values, actions), use ExtendedModel to layer behaviour on top of the generated base.
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
extend-for-behaviour | | | — | Generated models should be extended via to add computed values and model actions, keeping the schema-derived base clean. |
import { z } from 'zod'import { Model, prop, tProp, types, ExtendedModel } from 'mobx-keystone'
function zodToKeystoneType(zodType: z.ZodTypeAny): any { if (zodType instanceof z.ZodString) return types.string if (zodType instanceof z.ZodNumber) return types.number if (zodType instanceof z.ZodBoolean) return types.boolean if (zodType instanceof z.ZodOptional) return types.maybe(zodToKeystoneType(zodType.unwrap())) if (zodType instanceof z.ZodNullable) return types.maybeNull(zodToKeystoneType(zodType.unwrap())) if (zodType instanceof z.ZodArray) return types.array(zodToKeystoneType(zodType.element)) throw new Error(`Unsupported Zod type: ${zodType.constructor.name}`)}
function zodToKeystoneProp(zodType: z.ZodTypeAny) { if (zodType instanceof z.ZodDefault) return tProp(zodToKeystoneType(zodType.removeDefault())) .withDefault(zodType._def.defaultValue()) return tProp(zodToKeystoneType(zodType))}
function modelFromZod<T extends z.ZodObject<any>>(modelType: string, schema: T) { const keystoneProps = Object.fromEntries( Object.entries(schema.shape).map(([key, zodType]) => [ key, zodToKeystoneProp(zodType as z.ZodTypeAny), ]) )
@model(modelType) class DerivedModel extends Model(keystoneProps as any) { static validate(data: unknown): z.infer<T> { return schema.parse(data) } static fromRaw(data: unknown) { return new DerivedModel(DerivedModel.validate(data)) } } return DerivedModel}
// Usage: extend for domain behaviourconst BaseTodo = modelFromZod('BaseTodo', TodoSelectSchema)
@model('Todo')class Todo extends ExtendedModel(BaseTodo, {}) { @computed get isCompleted() { return this.completedAt !== null }}Option A — SQLite Leads (Reactive Cache)
SQLite is the source of truth for persisted domain state. Writes go through a service layer directly to the database. Reactive queries (reactiveExecute) push fresh results into MobX Keystone models, which serve as a view-cache. The UI observes Keystone models via observer().
This option excels for read-heavy data, complex joins/aggregates, background sync, and offline-first scenarios where SQLite handles the heavy lifting.
UI components -> observe MobX Keystone models (read) -> call services (write)
MobX Keystone models -> shaped data + computed/derived values -> NO write logic
Services / use-cases -> domain logic, validation, business rules -> all writes go through here -> orchestrate transactions
op-sqlite reactive -> bridge: DB changes -> model updatesqueries -> registered at app init or model lifecycle
SQLite -> single source of truth for persisted state| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
models-no-writes | | | — | In Option A, MobX Keystone models must not contain write logic. All mutations flow through service functions that write to SQLite. Models expose only computed/derived values. |
Option B — Keystone Leads (Event Sourcing Lite)
MobX Keystone is the source of truth. Models are mutated directly via @modelAction. The onPatches listener translates Keystone patches into SQL statements that persist changes asynchronously. Inverse patches enable undo/redo that automatically generates compensating SQL.
This option excels for UI-driven state, complex domain logic, optimistic updates, and scenarios requiring undo/redo or time-travel debugging.
See blueprint patch-persistence for the full patch-to-SQL mechanism.
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
action-boundary-persistence | | | — | In Option B, SQL persistence must be triggered at the top-level action boundary, not per individual patch. Use to group all patches from one action into a single SQL transaction. |
Hybrid Architecture
Option A and Option B coexist cleanly because they serve different data lifecycles. Domain state that needs queries, sync, and durability uses Option A. Ephemeral UI state (drafts, editor state) uses Option B. The two never conflict because they operate on disjoint model subtrees.
UI editors -> bind to Draft models (Option B, MobX snapshot persistence)UI readers -> observe persisted models (Option A, DB reactive queries)
DraftStore -> MobX Keystone + JSON blob in SQLite kv table -> owns draft lifecycle, recovery, unsaved indicators
Services -> receive draft on save, write to domain SQLite tables -> discard draft on success
Domain models -> DB-backed, reactive queries, thinArchitecture
Components
drizzle-schema
Drizzle ORMDefines all SQLite tables via . Serves as the single schema source from which Zod schemas, TS types, and Keystone models are derived. Also produces type-safe query builders.
zod-schemas
ZodSelect, insert, and update schemas derived from Drizzle via , , . Used for runtime validation of query results and service inputs.
-
drizzle-schema— Schemas are generated from Drizzle table definitions.
keystone-models
MobX KeystoneDomain models generated from Zod schemas via , extended with computed values and model actions. Observed by UI components for reactive rendering. In Option A these are view-caches; in Option B they are the source of truth.
-
zod-schemas— Model shape derived from Zod select schemas.
reactive-query-bridge
op-sqlite / DrizzleBridges Drizzle’s type-safe query builder to op-sqlite’s . Uses Drizzle’s to extract SQL + params, subscribes via op-sqlite’s native change hook, and validates results through Zod before applying to Keystone models.
-
drizzle-schema— Builds queries using Drizzle's . -
zod-schemas— Validates reactive query results before applying to models. -
keystone-models— Applies validated results to Keystone stores via surgical reconciliation.
import { QueryBuilder } from 'drizzle-orm/sqlite-core'import { type SQLiteTable } from 'drizzle-orm/sqlite-core'import { type z } from 'zod'
const qb = new QueryBuilder()
// Extract table names from Drizzle table objectsfunction tableNames(...tables: SQLiteTable[]): string[] { return tables.map(t => t[Symbol.for('drizzle:Name')])}
type ReactiveQueryOptions<TSchema extends z.ZodTypeAny> = { query: ReturnType<typeof qb.select> tables: string[] schema: TSchema callback: (rows: z.infer<TSchema>[]) => void}
function reactiveQuery<TSchema extends z.ZodTypeAny>({ query, tables, schema, callback,}: ReactiveQueryOptions<TSchema>) { const { sql, params } = query.toSQL()
return opsqliteDb.reactiveExecute({ query: sql, arguments: params, tables, callback: (rows: unknown[]) => { const parsed = z.array(schema).parse(rows) callback(parsed) }, })}
// Usage — fully type-safe, no raw SQL stringsconst unsub = reactiveQuery({ query: qb .select({ id: todos.id, title: todos.title, completedAt: todos.completedAt }) .from(todos) .where(eq(todos.listId, listId)) .orderBy(desc(todos.createdAt)), tables: tableNames(todos), schema: TodoSelectSchema, callback: (rows) => { // rows: z.infer<typeof TodoSelectSchema>[] — fully typed applySnapshot(store.todos, rows) },})service-layer
TypeScriptDomain logic, validation, and business rules. All database writes flow through services. Services accept (Drizzle type) via constructor injection, making them testable with any compatible SQLite driver.
-
drizzle-schema— Executes type-safe Drizzle queries for reads and writes. -
zod-schemas— Validates inputs using insert/update Zod schemas.
type AppDatabase = ReturnType<typeof drizzle<typeof schema>>
class TodoService { constructor(private db: AppDatabase) {}
async create(input: z.infer<typeof TodoInsertSchema>) { await this.db.insert(todos).values(input) }
async update(id: string, input: z.infer<typeof TodoUpdateSchema>) { await this.db.update(todos).set(input).where(eq(todos.id, id)) }
async complete(id: string) { const todo = await this.db.select().from(todos) .where(eq(todos.id, id)).then(rows => rows[0])
if (todo.blockedBy) throw new Error('Cannot complete a blocked todo')
await this.db.update(todos) .set({ completedAt: Date.now() }) .where(eq(todos.id, id)) }}collection-store
MobX KeystoneUses keyed by entity ID for O(1) lookups during reconciliation. Surgical per-instance updates replace naive on arrays, avoiding the performance cliff at 1000+ items on low-end devices.
-
keystone-models— Holds model instances in an ObjectMap.
import { Model, prop, objectMap, ObjectMap, modelAction } from 'mobx-keystone'import { runInAction } from 'mobx'
@model('TodoStore')class TodoStore extends Model({ todosMap: prop<ObjectMap<Todo>>(() => objectMap()),}) { @computed get todos() { return Array.from(this.todosMap.values()) }
@modelAction addTodo(todo: Todo) { this.todosMap.set(todo.id, todo) }
@modelAction removeTodo(id: string) { this.todosMap.delete(id) }}
// Surgical reconciliation in reactive callbackfunction reconcileTodos(store: TodoStore, rows: TodoRow[]) { runInAction(() => { const rowMap = new Map(rows.map(r => [r.id, r]))
// Remove deleted for (const [id] of store.todosMap.items()) { if (!rowMap.has(id)) store.removeTodo(id) }
// Add new, update existing for (const [id, row] of rowMap) { const existing = store.todosMap.get(id) if (existing) { applySnapshot(existing, row) // per-instance reconcile } else { store.addTodo(Todo.fromRaw(row)) } } })}database-provider
op-sqliteProvides the SQLite database connection. In production, wraps with extensions (FTS5, sqlite-vec, rtree). Configured via .
{ "op-sqlite": { "fts5": true, "sqliteVec": true, "rtree": true, "performanceMode": true }}migration-runner
Drizzle KitGenerates migration SQL files at dev time via . Bundles migrations into the JS bundle via Babel inline-import plugin. Runs migrations on device at app startup via hook before any UI renders. Tracks applied migrations in table.
-
drizzle-schema— Generates migrations from schema diffs. -
database-provider— Applies migrations to the on-device database.
// babel.config.js — inline .sql files into the JS bundlemodule.exports = { presets: ['module:@react-native/babel-preset'], plugins: [['inline-import', { extensions: ['.sql'] }]],}
// App.tsx — run migrations before renderingimport { useMigrations } from 'drizzle-orm/op-sqlite/migrator'import migrations from './drizzle/migrations'
const opsqliteDb = open({ name: 'app.db' })const db = drizzle(opsqliteDb, { schema })
export default function App() { const { success, error } = useMigrations(db, migrations) if (error) return <MigrationErrorScreen error={error} /> if (!success) return <SplashScreen /> return <RootNavigator />}Rules
| Rule | Force | Realm | Reference | Description |
|---|---|---|---|---|
drizzle-is-source | | | — | Schema definitions must originate in Drizzle table declarations. Hand-written Zod schemas are only acceptable for non-persisted state (drafts, UI-only models). |
extend-for-behaviour | | | — | Generated models should be extended via to add computed values and model actions, keeping the schema-derived base clean. |
models-no-writes | | | — | In Option A, MobX Keystone models must not contain write logic. All mutations flow through service functions that write to SQLite. Models expose only computed/derived values. |
action-boundary-persistence | | | — | In Option B, SQL persistence must be triggered at the top-level action boundary, not per individual patch. Use to group all patches from one action into a single SQL transaction. |
forward-only-migrations | | | — | Shipped migrations can never be rolled back. Any fix must be a forward migration. Destructive changes (dropping columns/tables) must be split into a deprecation migration followed by a removal migration in a later version. |
debounce-reactive-callbacks | | | — | Reactive query callbacks should be debounced (50-100ms) when the underlying tables receive high-frequency writes (sync, bulk imports) to prevent redundant reconciliation work. |