createModel
CRUD model factory for Drizzle tables. Replaces repetitive safeTry + handleError + null-check boilerplate with a single function call.
Signature
import { createModel } from '@nilejs/nile';
const taskModel = createModel(table, options);
function createModel<TTable, TDB>(
table: TTable,
options: ModelOptions<TDB>
): ModelOperations<TSelect, TInsert, TDB>
table (required)
A Drizzle table definition created via pgTable, sqliteTable, etc.
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const tasks = pgTable('tasks', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description'),
status: text('status', { enum: ['pending', 'in-progress', 'done'] })
.notNull()
.default('pending'),
created_at: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
});
Must be a real Drizzle table object — it carries internal Symbol-keyed metadata that getZodSchema (via drizzle-zod) needs to auto-generate validation schemas. Plain objects will fail.
options: ModelOptions<TDB> (required)
interface ModelOptions<TDB = unknown> {
db?: TDB;
name: string;
cursorColumn?: string;
}
options.name (required)
Human-readable entity name. Used in error messages and handleError attribution.
createModel(tasks, { name: 'task' });
// Error messages: "Task not found", "Error creating task"
// atFunction values: "task.create", "task.findById", etc.
Auto-capitalized for user-facing messages: "task" → "Task not found".
options.db (optional)
Explicit Drizzle database instance. Accepts any Drizzle driver (Neon, PGLite, etc.).
import { db } from './client';
// Explicit — db is fixed at factory creation time
const model = createModel(tasks, { db, name: 'task' });
// Omitted — db resolved from Nile context at each method call
const model = createModel(tasks, { name: 'task' });
When omitted, each method call resolves the db via getContext().resources.database. This supports request-scoped database access in Nile's context system.
Throws immediately if neither explicit db nor context db is available: "createModel: No database available.".
options.cursorColumn (optional, default: "id")
Default column name for cursor-based pagination. Can be overridden per-query.
// Model-level: paginate by created_at by default
const model = createModel(tasks, { db, name: 'task', cursorColumn: 'created_at' });
// Per-query override
await model.findPaginated({ limit: 20, cursor: 'abc', cursorColumn: 'id' });
Output
Returns ModelOperations<TSelect, TInsert, TDB> where type parameters are inferred from the Drizzle table:
TSelect — row type from select queries (table.$inferSelect)
TInsert — data type for inserts (table.$inferInsert)
TDB — database type for transaction support
All async methods return Result<T, string> from slang-ts.
CRUD Methods
create({ data, dbx? })
Insert a new record with auto-validation.
create(params: {
data: TInsert; // Validated against auto-generated insert schema
dbx?: DBX<TDB>; // Optional transaction pointer
}): Promise<Result<TSelect, string>>
Returns Err if validation fails, insert returns empty, or db throws.
createTx({ data, dbx? })
Same as create, wrapped in a database transaction via createTransactionVariant. Rolls back on Err.
findById(id)
Find a single record by UUID.
findById(id: string): Promise<Result<TSelect, string>>
Returns Err("{Name} not found") when no row matches.
update({ id, data, dbx? })
Update a record by UUID with auto-validation.
update(params: {
id: string;
data: Partial<TSelect>; // Validated against auto-generated update schema
dbx?: DBX<TDB>;
}): Promise<Result<TSelect, string>>
Returns Err("{Name} not found") when no row matches the id.
updateTx({ id, data, dbx? })
Same as update, wrapped in a database transaction. Rolls back on Err.
delete(id)
Delete a record by UUID, returns the deleted row.
delete(id: string): Promise<Result<TSelect, string>>
findAll()
Get all records. Auto-orders by created_at or createdAt descending when that column exists on the table.
findAll(): Promise<Result<TSelect[], string>>
Returns Ok([]) for empty tables — not an error.
findPaginated(options?)
Two modes, determined by which options are passed.
Offset mode (default — no cursor provided):
await model.findPaginated({ limit: 20, offset: 0 });
interface OffsetPaginationOptions {
limit?: number; // Default: 50
offset?: number; // Default: 0
}
Returns:
interface OffsetPage<T> {
items: T[];
total: number; // Total count across all pages
hasMore: boolean; // offset + items.length < total
}
Cursor mode (when cursor is provided):
await model.findPaginated({ limit: 20, cursor: 'abc-123', cursorColumn: 'created_at' });
interface CursorPaginationOptions {
limit?: number; // Default: 50
cursor: string; // Value to paginate from
cursorColumn?: string; // Overrides model-level default
}
Returns:
interface CursorPage<T> {
items: T[];
nextCursor: string | null; // Pass as cursor for next page
hasMore: boolean;
}
Uses lt() on the cursor column with desc ordering. Fetches limit + 1 rows to determine hasMore without a separate count query.
Escape Hatches
table
The original Drizzle table. Use for custom queries beyond CRUD.
const db = getContext().resources.database;
const active = await db.select().from(model.table).where(eq(model.table.status, 'active'));
schemas
Auto-generated Zod schemas from the Drizzle table via getZodSchema.
model.schemas.insert // For validating create data
model.schemas.update // For validating update data
model.schemas.select // For validating query results
Example
Model definition
// db/models/tasks.ts
import { createModel } from '@nilejs/nile';
import { tasks } from '../schema';
import { db } from '../client';
export const taskModel = createModel(tasks, { db, name: 'task' });
Usage in action handlers
// services/tasks/create.ts
import { taskModel } from '../../db/models';
const handler = async (data: Record<string, unknown>) => {
const result = await taskModel.create({
data: { title: data.title as string },
});
if (result.isErr) return Err(result.error);
return Ok({ task: result.value });
};
// services/tasks/list.ts
const handler = async () => {
return taskModel.findAll();
};
// services/tasks/get.ts
const handler = async (data: Record<string, unknown>) => {
return taskModel.findById(data.taskId as string);
};
Custom queries via escape hatch
import { eq } from 'drizzle-orm';
const db = getContext().resources.database;
const pending = await db
.select()
.from(taskModel.table)
.where(eq(taskModel.table.status, 'pending'));
Internals
DB Resolution
method call → options.db exists? → use it
↓ no
getContext().resources.database exists? → use it
↓ no
throw "No database available"
Resolution is lazy (at call time, not factory creation) so context-based apps work correctly.
Validation Flow
create/update → schemas.insert/update.safeParse(data)
↓ fails → handleError("Invalid {name} data")
↓ passes → execute db query
Error Handling
All errors go through handleError:
- Resolves logger from explicit param or Nile context
- Logs with
atFunction attribution (e.g. task.create)
- Returns
Err("[logId] message") with traceable log ID
Auto-Ordering
findAll and offset findPaginated auto-detect timestamp columns:
- Checks for
created_at on the table
- Falls back to
createdAt
- No ordering applied if neither exists
Failure Modes
Pairing with createServices
createModel eliminates model boilerplate. Pair it with createServices to eliminate the service/action layer boilerplate too. Together they reduce a full CRUD service from ~250 lines across 7 files to ~40 lines across 3 files.
The pattern
Schema (pgTable)
└─ createModel → CRUD model (1 line)
└─ Service config (with direct action arrays) → CRUD actions (5 action definitions)
└─ done
Step 1: Model — one line
// db/models/tasks.ts
import { createModel } from '@nilejs/nile';
import { tasks } from '../schema';
import { db } from '../client';
export const taskModel = createModel(tasks, { db, name: 'task' });
Step 2: Actions — each handler calls one model method
// services/tasks/create.ts
import { createAction, type Action } from '@nilejs/nile';
import { Err, Ok } from 'slang-ts';
import z from 'zod';
import { taskModel } from '@/db/models';
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional().default(''),
status: z.enum(['pending', 'in-progress', 'done']).optional().default('pending'),
});
const createTaskHandler = async (data: Record<string, unknown>) => {
const result = await taskModel.create({
data: {
title: data.title as string,
description: (data.description as string) ?? '',
status: (data.status as 'pending' | 'in-progress' | 'done') ?? 'pending',
},
});
if (result.isErr) return Err(result.error);
return Ok({ task: result.value });
};
export const createTaskAction: Action = createAction({
name: 'create',
description: 'Create a new task',
handler: createTaskHandler,
validation: createTaskSchema,
});
// services/tasks/list.ts
import { createAction, type Action } from '@nilejs/nile';
import { Err, Ok } from 'slang-ts';
import { taskModel } from '@/db/models';
const listTasksHandler = async () => {
const result = await taskModel.findAll();
if (result.isErr) return Err(result.error);
return Ok({ tasks: result.value });
};
export const listTaskAction: Action = createAction({
name: 'list',
description: 'List all tasks',
handler: listTasksHandler,
});
// services/tasks/get.ts
const getTaskHandler = async (data: Record<string, unknown>) => {
const result = await taskModel.findById(data.id as string);
if (result.isErr) return Err(result.error);
return Ok({ task: result.value });
};
// services/tasks/update.ts
const updateTaskHandler = async (data: Record<string, unknown>) => {
const { id, ...updates } = data;
const result = await taskModel.update({ id: id as string, data: updates });
if (result.isErr) return Err(result.error);
return Ok({ task: result.value });
};
// services/tasks/delete.ts
const deleteTaskHandler = async (data: Record<string, unknown>) => {
const result = await taskModel.delete(data.id as string);
if (result.isErr) return Err(result.error);
return Ok({ deleted: true, id: data.id });
};
Step 3: Wire into services
// services/services.config.ts
import { createServices, type Services } from '@nilejs/nile';
import { createTaskAction } from './tasks/create';
import { deleteTaskAction } from './tasks/delete';
import { getTaskAction } from './tasks/get';
import { listTaskAction } from './tasks/list';
import { updateTaskAction } from './tasks/update';
export const services: Services = createServices([
{
name: 'tasks',
description: 'Task management with CRUD operations',
actions: [
createTaskAction,
listTaskAction,
getTaskAction,
updateTaskAction,
deleteTaskAction,
],
},
]);
What each layer handles
When to go beyond this pattern
The createModel + createServices pattern covers standard CRUD. Go custom when you need:
- Complex queries: Joins, aggregations, CTEs — use
model.table escape hatch with raw Drizzle
- Multi-model operations: Actions that span multiple models or have complex business logic
- Non-CRUD actions: Search, bulk operations, file processing — write a custom handler
- Custom hooks: Before/after hooks that modify data or enforce business rules — use the hooks system
vs Manual Model Files
createModel replaces the manual model pattern documented in the Database Overview. Use it for standard CRUD. For custom queries, complex joins, or non-standard patterns, write manual model functions using safeTry + handleError directly and use the table escape hatch.