Drizzle Overview
Mion's Drizzle integration follows RunTypes: TypeScript types express database settings and TypeFormats metadata. Those types describe your schema and generate validation, mock data and JSON conversion for server and client.
One package per database gives you the Drizzle ORM table functions for that database, with the same names, config, indexes and constraints. A column takes all its settings in one object instead of a chain of calls.
| Database | Package |
|---|---|
| PostgreSQL | @mionjs/drizzle-orm-pg-core |
| MySQL | @mionjs/drizzle-orm-mysql-core |
| SQLite | @mionjs/drizzle-orm-sqlite-core |
| Cloudflare D1, Durable Objects | @mionjs/drizzle-orm-sqlite-core |
Cloudflare's two databases are SQLite and only change the driver: see Cloudflare D1 and Durable Objects.
Package versions follow drizzle-orm, not mion: version 0.45.x works with drizzle-orm 0.45.x and declares that range as a peer dependency. Pick the version that matches your drizzle-orm. A new version ships only when the package changes, so one version can stay current across several mion releases.
Install the database package with its shared core. drizzle-orm is an optional peer: your schema, models and validators work without it.
npm install @mionjs/drizzle-orm-pg-core @mionjs/drizzle-orm @mionjs/run-types
npm install drizzle-orm # only needed to run queries or migrations
These builders record calls instead of running Drizzle. Their models keep column TypeFormats parameters, including string lengths and numeric bounds.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
// a recorded table, not drizzle's PgTable: toDrizzle() builds that on demand
export const users = DZ.pgTable('users', {
id: DZ.uuid('id', {primaryKey: true}),
name: DZ.varchar('name', {length: 100, notNull: true}),
age: DZ.integer('age', {notNull: true}),
role: DZ.text('role', {enum: ['admin', 'user'], notNull: true}),
createdAt: DZ.timestamp('created_at', {defaultNow: true, notNull: true}),
});
// { id: UUID; name: String<{maxLength: 100}>; age: Int32; role: 'admin' | 'user'; createdAt: Date }
export type User = InferSelectModel<typeof users>;
// checks every captured param, with no extra runtime checks
export const validateUser = createValidateFn<User>();
export const checks = [
validateUser({
id: 'not-a-uuid',
name: 'ann',
age: 30,
role: 'admin',
createdAt: new Date(),
}), // false
validateUser({
id: '793aff46-42ac-4372-b7fa-c48ba48ed94f',
name: 'x'.repeat(101), // false: maxLength 100 is IN the compiled function
age: 30,
role: 'admin',
createdAt: new Date(),
}),
];
import * as DZ from '@mionjs/drizzle-orm-mysql-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
// a recorded table, not drizzle's MySqlTable: toDrizzle() builds that on demand
export const devices = DZ.mysqlTable('devices', {
serialNo: DZ.varchar('serial_no', {length: 12, notNull: true}),
views: DZ.int('views', {unsigned: true, notNull: true}), // UInt32: 0 to 4294967295
offsetC: DZ.tinyint('offset_c', {notNull: true}), // Int8: -128 to 127
builtIn: DZ.year('built_in', {notNull: true}), // 1901 to 2155
});
export type Device = InferSelectModel<typeof devices>;
export const validateDevice = createValidateFn<Device>();
// false: views is negative and offsetC is beyond Int8
export const check = validateDevice({
serialNo: 'SN-1',
views: -1,
offsetC: 200,
builtIn: 2020,
});
import * as DZ from '@mionjs/drizzle-orm-sqlite-core';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
export const notes = DZ.sqliteTable('notes', {
id: DZ.integer('id', {primaryKey: true}),
title: DZ.text('title', {length: 80, notNull: true}),
rating: DZ.real('rating', {notNull: true}),
createdAt: DZ.integer('created_at', {mode: 'timestamp', notNull: true}), // a real Date
});
export type NewNote = InferInsertModel<typeof notes>;
export type Note = InferSelectModel<typeof notes>;
export const validateNote = createValidateFn<Note>();
// false: title exceeds 80 characters
export const check = validateNote({
id: 1,
title: 'x'.repeat(81),
rating: 4.5,
createdAt: new Date(),
});
To type mion routes with these models, see Drizzle Tables.
Column Settings
A column builder takes the database column name, then one object with drizzle's config and its modifiers. A modifier with no arguments is true, one with arguments is its argument list.
// drizzle
varchar('name', {length: 100}).notNull().default('anon');
// mion
varchar('name', {length: 100, notNull: true, default: ['anon']});
name: varchar({length: 100}).Writing a Table as a Type
Each column builder has a column type of the same name, first letter uppercased, that takes the same settings object: Varchar<{length: 100; notNull: true}>. A builder table's typeof users equals the same table written by hand, so you can mix both forms per table. mion convert translates a table between the two forms in either direction.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
// nothing runs where the table is declared
export type UsersTable = DZ.PgTable<
'users',
{
id: DZ.Uuid<{primaryKey: true}>;
name: DZ.Varchar<{length: 100; notNull: true}>;
age: DZ.Integer<{notNull: true}>;
role: DZ.Text<{enum: ['admin', 'user']; notNull: true}>;
createdAt: DZ.Timestamp<{defaultNow: true; notNull: true}>;
},
[],
{createdAt: 'created_at'}
>; // database names that differ from the key
// the same object pgTable returns; the build resolves the type, nothing is repeated
export const users = DZ.tableFromType<UsersTable>();
// identical to the builders model, formats included
export type User = InferSelectModel<UsersTable>;
// checks every captured param, with no extra runtime checks
export const validateUser = createValidateFn<User>();
export const checks = [
validateUser({
id: 'not-a-uuid',
name: 'ann',
age: 30,
role: 'admin',
createdAt: new Date(),
}), // false
validateUser({
id: '793aff46-42ac-4372-b7fa-c48ba48ed94f',
name: 'x'.repeat(101), // false: maxLength 100 is IN the compiled function
age: 30,
role: 'admin',
createdAt: new Date(),
}),
];
import * as DZ from '@mionjs/drizzle-orm-mysql-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
export type DevicesTable = DZ.MysqlTable<
'devices',
{
serialNo: DZ.Varchar<{length: 12; notNull: true}>;
views: DZ.Int<{unsigned: true; notNull: true}>; // UInt32: 0 to 4294967295
offsetC: DZ.Tinyint<{notNull: true}>; // Int8: -128 to 127
builtIn: DZ.Year<{notNull: true}>; // 1901 to 2155
},
[],
{serialNo: 'serial_no'; offsetC: 'offset_c'; builtIn: 'built_in'}
>; // database names that differ from the key
// the recorded table, ready for toDrizzle
export const devices = DZ.tableFromType<DevicesTable>();
export type Device = InferSelectModel<DevicesTable>;
export const validateDevice = createValidateFn<Device>();
// false: views is negative and offsetC is beyond Int8
export const check = validateDevice({
serialNo: 'SN-1',
views: -1,
offsetC: 200,
builtIn: 2020,
});
import * as DZ from '@mionjs/drizzle-orm-sqlite-core';
import type {InferSelectModel} from '@mionjs/drizzle-orm';
import {createValidateFn} from '@mionjs/run-types';
export type NotesTable = DZ.SqliteTable<
'notes',
{
id: DZ.Integer<{primaryKey: true}>;
title: DZ.Text<{length: 80; notNull: true}>;
rating: DZ.Real<{notNull: true}>;
createdAt: DZ.Integer<{mode: 'timestamp'; notNull: true}>; // a real Date
},
[],
{createdAt: 'created_at'}
>; // database names that differ from the key
// the recorded table, ready for toDrizzle
export const notes = DZ.tableFromType<NotesTable>();
export type Note = InferSelectModel<NotesTable>;
export const validateNote = createValidateFn<Note>();
// false: title is longer than the captured 80 char limit
export const check = validateNote({
id: 1,
title: 'x'.repeat(81),
rating: 4.5,
createdAt: new Date(),
});
Building the Drizzle Table
pgTable records a table; toDrizzle builds its Drizzle table once and returns it on later calls. Call it in your database file. toDrizzle<UsersTable>() builds the same table from its type alone, with no other schema imports in your query file.
import {gte} from 'drizzle-orm';
import {drizzle} from 'drizzle-orm/pg-proxy';
import {toDrizzle} from '@mionjs/drizzle-orm-pg-core/drizzle';
import {users, type User} from './drizzle-proxy-pg-example.ts';
// a genuine drizzle table, also what a drizzle-kit schema file exports
export const usersDb = toDrizzle(users);
// any drizzle driver works; swap in node-postgres, pglite or neon in a real app
const db = drizzle(async () => ({rows: []}));
export async function listAdults(): Promise<User[]> {
// typed rows, formats intact: the same User the routes use
return db.select().from(usersDb).where(gte(usersDb.age, 18));
}
import {gte} from 'drizzle-orm';
import {drizzle} from 'drizzle-orm/pg-proxy';
import {toDrizzle} from '@mionjs/drizzle-orm-pg-core/drizzle';
import type {UsersTable, User} from './drizzle-types-pg-example.ts';
// a genuine drizzle table, also what a drizzle-kit schema file exports
export const usersDb = toDrizzle<UsersTable>();
// any drizzle driver works; swap in node-postgres, pglite or neon in a real app
const db = drizzle(async () => ({rows: []}));
export async function listAdults(): Promise<User[]> {
// typed rows, formats intact: the same User the routes use
return db.select().from(usersDb).where(gte(usersDb.age, 18));
}
A drizzle-kit schema file exports the same call (and the materialized enums, for pgEnum). Operators, relations, getTableConfig and views built from a query builder all work on the result as usual.
Organising Schema and Database Files
Keep slim tables and models in a schema file, and toDrizzle, relations, the database connection and queries in a database file that imports it. The linter fails the build when a file defining slim tables or models imports Drizzle or toDrizzle (drizzle-mixed-types). It warns when a route takes or returns a Drizzle type (drizzle-type-not-allowed).
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {tableRef, type InferSelectModel} from '@mionjs/drizzle-orm';
export const authors = DZ.pgTable('authors', {
id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
name: DZ.varchar('name', {length: 100, notNull: true}),
});
export const posts = DZ.pgTable('posts', {
id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
authorId: DZ.uuid('author_id', {
notNull: true,
references: [() => tableRef(authors, 'id'), {onDelete: 'cascade'}],
}),
title: DZ.varchar('title', {length: 200, notNull: true}),
});
// route and client models
export type Author = InferSelectModel<typeof authors>;
export type Post = InferSelectModel<typeof posts>;
// start-nested-model
export interface AuthorWithPosts extends Author {
posts: Post[];
}
// end-nested-model
import {authors, posts} from './drizzle-relations-schema-example.ts';
import {toDrizzle} from '@mionjs/drizzle-orm-pg-core/drizzle';
import {relations} from 'drizzle-orm';
import type {ExtractTablesWithRelations} from 'drizzle-orm';
import type {PgDatabase, PgQueryResultHKT} from 'drizzle-orm/pg-core';
const authorsDb = toDrizzle(authors);
const postsDb = toDrizzle(posts);
// relations need real Drizzle tables
const authorsRelations = relations(authorsDb, ({many}) => ({
posts: many(postsDb),
}));
const postsRelations = relations(postsDb, ({one}) => ({
author: one(authorsDb, {
fields: [postsDb.authorId],
references: [authorsDb.id],
}),
}));
// passed to drizzle(client, {schema})
export const schema = {
authors: authorsDb,
posts: postsDb,
authorsRelations,
postsRelations,
};
declare const db: PgDatabase<
PgQueryResultHKT,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>;
// nested results
export async function authorsWithPosts() {
return db.query.authors.findMany({with: {posts: true}});
}
A client importing a model then type-checks only the schema file. With both in one file, a full type check also checks every conversion, relation and query, and a value import bundles drizzle-orm. Your editor costs the same either way.
Recorded Tables From a Type
Where you need the recorded table without drizzle (models, refinement, mion routes), use tableFromType<UsersTable>() from the package root. Its options take the two runtime inputs a type cannot hold: the tables a column references ({tables: {parents: parents}}, or {tables: {parents: () => parents}} when that table is declared further down the file) and the runtime callbacks described below.
Refining Tables for Your API
refineTableType adds API rules stricter than the database column: a name that holds 100 characters can require at least 10. It returns the same table object with the extra rules added, and the SQL does not change. Build validators, mock data and JSON round trip functions from the refined models, with or without mion.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {refineTableType} from '@mionjs/drizzle-orm';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
import {
createJsonDecoderFn,
createJsonEncoderFn,
createValidateFn,
} from '@mionjs/run-types';
import {createMockDataFn} from '@mionjs/run-types/mocking';
export const users = DZ.pgTable('users', {
id: DZ.uuid('id', {primaryKey: true, defaultRandom: true}),
name: DZ.varchar('name', {length: 100, notNull: true}), // captured as String<{maxLength: 100}>
age: DZ.integer('age', {notNull: true}),
createdAt: DZ.timestamp('created_at', {
mode: 'date',
notNull: true,
defaultNow: true,
}),
});
// stricter types for the API
export const apiUsers = refineTableType(users, {
name: {minLength: 10},
age: {min: 18},
});
export type User = InferSelectModel<typeof apiUsers>; // name: String<{maxLength: 100, minLength: 10}>
export type NewUser = InferInsertModel<typeof apiUsers>; // id and createdAt optional (DB defaults)
export type UserPatch = Partial<InferInsertModel<typeof apiUsers>>;
export const validateUser = createValidateFn<User>();
export const mockUser = createMockDataFn<User>(); // realistic rows that pass validateUser
export const encodeUser = createJsonEncoderFn<User>();
export const decodeUser = createJsonDecoderFn<User>();
// checks the captured AND the refined bounds
export const checks = [
validateUser(mockUser()), // true: mock data respects every bound
validateUser({...mockUser(), name: 'short'}), // false: refined minLength 10
validateUser({...mockUser(), age: 17}), // false: refined min 18
];
const wire = encodeUser(mockUser())!; // a JSON string, the Date made wire-safe
export const restored = decodeUser(wire); // restored.createdAt instanceof Date
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {refineTableType} from '@mionjs/drizzle-orm';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
import {
createJsonDecoderFn,
createJsonEncoderFn,
createValidateFn,
} from '@mionjs/run-types';
import {createMockDataFn} from '@mionjs/run-types/mocking';
export type UsersTable = DZ.PgTable<
'users',
{
id: DZ.Uuid<{primaryKey: true; defaultRandom: true}>;
name: DZ.Varchar<{length: 100; notNull: true}>; // captured as String<{maxLength: 100}>
age: DZ.Integer<{notNull: true}>;
createdAt: DZ.Timestamp<{mode: 'date'; notNull: true; defaultNow: true}>;
},
[],
{createdAt: 'created_at'}
>;
export const users = DZ.tableFromType<UsersTable>();
// stricter types for the API
export const apiUsers = refineTableType(users, {
name: {minLength: 10},
age: {min: 18},
});
export type User = InferSelectModel<typeof apiUsers>; // name: String<{maxLength: 100, minLength: 10}>
export type NewUser = InferInsertModel<typeof apiUsers>; // id and createdAt optional (DB defaults)
export type UserPatch = Partial<InferInsertModel<typeof apiUsers>>;
export const validateUser = createValidateFn<User>();
export const mockUser = createMockDataFn<User>(); // realistic rows that pass validateUser
export const encodeUser = createJsonEncoderFn<User>();
export const decodeUser = createJsonDecoderFn<User>();
// checks the captured AND the refined bounds
export const checks = [
validateUser(mockUser()), // true: mock data respects every bound
validateUser({...mockUser(), name: 'short'}), // false: refined minLength 10
validateUser({...mockUser(), age: 17}), // false: refined min 18
];
const wire = encodeUser(mockUser())!; // a JSON string, the Date made wire-safe
export const restored = decodeUser(wire); // restored.createdAt instanceof Date
A refinement that does not fit the column is a compile error, never a silent override: a number rule on a string column, an unknown parameter, or a change to whether the column accepts null (that stays with notNull on the table). Columns without a format (booleans, JSON, enums) cannot be refined.
Refined Table Types
RefinedTable does the same refinement as a type, with no table value: it takes the table type and the per-column rules, so a types-only file can export the API view of a table.
import type {RefinedTable} from '@mionjs/drizzle-orm';
import type {UsersTable} from './schema.ts';
type ApiUsersTable = RefinedTable<UsersTable, {name: {minLength: 10}; age: {min: 18}}>;
Insert, Select and Update Payloads
InferSelectModel and InferInsertModel keep drizzle's names, so a schema file ports by changing only the import. They come from @mionjs/drizzle-orm, with refineTableType and the sql template, and work on any table, refined or not: inserts drop generated columns and make defaulted ones optional. As in drizzle, an update is Partial<InferInsertModel<typeof users>>. From a table type, the file is types only.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
export const users = DZ.pgTable('users', {
id: DZ.uuid('id', {primaryKey: true, defaultRandom: true}),
email: DZ.varchar('email', {length: 254, notNull: true}),
name: DZ.varchar('name', {length: 100, notNull: true}),
bio: DZ.varchar('bio', {length: 500}),
createdAt: DZ.timestamp('created_at', {defaultNow: true, notNull: true}),
});
// every key present, bio is value | null
export type User = InferSelectModel<typeof users>;
// id and createdAt have defaults, so inserts may omit them
export type NewUser = InferInsertModel<typeof users>;
export type UserPatch = Partial<InferInsertModel<typeof users>>;
// a NewUser route input checks the varchar lengths and goes into db.insert(...).values() uncast
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferInsertModel, InferSelectModel} from '@mionjs/drizzle-orm';
export type UsersTable = DZ.PgTable<
'users',
{
id: DZ.Uuid<{primaryKey: true; defaultRandom: true}>;
email: DZ.Varchar<{length: 254; notNull: true}>;
name: DZ.Varchar<{length: 100; notNull: true}>;
bio: DZ.Varchar<{length: 500}>;
createdAt: DZ.Timestamp<{defaultNow: true; notNull: true}>;
},
[],
{createdAt: 'created_at'}
>;
// every key present, bio is value | null
export type User = InferSelectModel<UsersTable>;
// id and createdAt have defaults, so inserts may omit them
export type NewUser = InferInsertModel<UsersTable>;
export type UserPatch = Partial<InferInsertModel<UsersTable>>;
// a NewUser route input checks the varchar lengths and goes into db.insert(...).values() uncast
typeof users.$inferSelect, and a table typeof users.$inferInsert. They match InferSelectModel and InferInsertModel. On a table type, write them as UsersTable['$inferSelect'].Runtime Column Callbacks
A builder takes Drizzle's $default, $defaultFn, $onUpdate and $onUpdateFn callbacks as a one-item list: $defaultFn: [() => crypto.randomUUID()]. A type cannot hold a function, so a table type sets the same prop to true and the callback goes in the runtime option of tableFromType or toDrizzle. A column with any of these props counts as having a default, so it is optional on insert, even in files that only import the type.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import type {InferInsertModel} from '@mionjs/drizzle-orm';
export type JobsTable = DZ.PgTable<
'jobs',
{
id: DZ.Uuid<{primaryKey: true}>;
slug: DZ.Varchar<{length: 80; notNull: true; $defaultFn: true}>;
updatedAt: DZ.Timestamp<{mode: 'string'; $onUpdate: true}>;
},
[],
{updatedAt: 'updated_at'}
>;
// a missing or extra callback throws at startup, naming the column
export const jobs = DZ.tableFromType<JobsTable>({
runtime: {
slug: {$defaultFn: () => crypto.randomUUID().slice(0, 8)},
updatedAt: {$onUpdate: () => new Date().toISOString()},
},
});
// slug is notNull, but the $defaultFn flag makes it optional on insert
export type NewJob = InferInsertModel<JobsTable>;
export const minimalInsert: NewJob = {id: crypto.randomUUID()};
mion convert moves the callbacks between the two forms in both directions, text intact.
Custom JSON Types
Columns with no matching format (geometry, vectors, MAC addresses, binary data, plain booleans and JSON) keep their plain data type. For a JSON column, declare your own type with the $type prop.
import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {$type} from '@mionjs/drizzle-orm';
const events = DZ.pgTable('events', {
id: DZ.uuid({primaryKey: true}),
payload: DZ.jsonb({$type: $type<{kind: string; at: number}>(), notNull: true}),
});
import * as DZ from '@mionjs/drizzle-orm-pg-core';
type EventsTable = DZ.PgTable<
'events',
{
id: DZ.Uuid<{primaryKey: true}>;
payload: DZ.Jsonb<{$type: [{kind: string; at: number}]; notNull: true}>;
}
>;