Indexes
Drizzle-style indexes and constraints, enforced at runtime in the ORM
The ORM mirrors Drizzle's schema API where it makes sense, but enforces constraints at runtime (in ORM mutations), not inside a SQL database.
Important: Constraints are enforced only by ORM mutations (insert/update/delete). Direct ctx.db writes bypass them.
Constraints
Default
Use .default(value) to define a default for inserts. Defaults are applied by ORM inserts when the value is undefined. Explicit null is preserved.
import { convexTable, integer, text } from 'kitcn/orm';
export const posts = convexTable('posts', {
title: text().notNull(),
viewCount: integer().default(0),
});Not Null
Fields are nullable by default. Use .notNull() to require a value.
import { convexTable, text } from 'kitcn/orm';
export const users = convexTable('users', {
email: text().notNull(),
bio: text(),
});Unique
You can model uniqueness in a few Drizzle-style ways.
uniqueIndex()
import { convexTable, text, uniqueIndex } from 'kitcn/orm';
export const users = convexTable(
'users',
{
email: text().notNull(),
},
(t) => [uniqueIndex('users_email_unique').on(t.email)]
);unique()
For compound uniqueness, use unique('name').on(...) with multiple columns:
import { convexTable, text, unique } from 'kitcn/orm';
export const users = convexTable(
'users',
{
firstName: text().notNull(),
lastName: text().notNull(),
},
(t) => [unique('full_name').on(t.firstName, t.lastName)]
);You can use .nullsNotDistinct() for PostgreSQL parity when you want null to be treated as a value.
Important: Uniqueness is enforced at runtime and is best-effort within a mutation. Concurrent mutations can still race. For strict guarantees, serialize writes per key or use onConflictDoNothing() / retries.
Column .unique()
For single-column uniqueness, you can call .unique() directly on the column builder:
import { convexTable, text } from 'kitcn/orm';
export const users = convexTable('users', {
email: text().notNull().unique(),
});Foreign Keys
Foreign keys are enforced at runtime by the ORM mutation builders.
Column .references()
import { convexTable, id, text } from 'kitcn/orm';
export const users = convexTable('users', {
email: text().notNull(),
});
export const posts = convexTable('posts', {
title: text().notNull(),
authorId: id('users').notNull().references(() => users.id),
});foreignKey()
For referencing non-id columns, use the foreignKey() builder:
import { convexTable, foreignKey, text } from 'kitcn/orm';
export const users = convexTable('users', {
slug: text().notNull().unique(),
});
export const memberships = convexTable(
'memberships',
{
userSlug: text().notNull(),
},
(t) => [
foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] }),
]
);Self-Referencing Foreign Keys
When a table references itself (e.g. a tree of comments), annotate the callback return type with AnyColumn to avoid circular-reference errors:
import { type AnyColumn, convexTable, text } from 'kitcn/orm';
export const comments = convexTable('comments', {
body: text().notNull(),
parentId: text().references((): AnyColumn => comments.id, {
onDelete: 'cascade',
}),
});Actions (onDelete / onUpdate)
The ORM supports Drizzle-style foreign key actions, enforced at runtime by ORM mutations.
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull().references(() => users.id, {
onDelete: 'cascade',
}),
},
(t) => [index('by_author').on(t.authorId)]
);Tip: For non-id foreign keys, add an index on the referenced columns (parent table) so inserts/updates can validate quickly. For cascade / set null / set default, add an index on the referencing columns (child table). Without an index, the ORM will throw if it detects referencing rows.
Check Constraints
Use check(name, expression) to enforce invariants on inserts and updates.
import { check, convexTable, gt, integer, isNotNull, text } from 'kitcn/orm';
export const users = convexTable(
'users',
{
age: integer(),
email: text(),
},
(t) => [
check('age_over_18', gt(t.age, 18)),
check('email_present', isNotNull(t.email)),
]
);Note: Checks follow SQL-like NULL semantics: they fail only when the expression evaluates to false. If it evaluates to unknown (because of null/undefined), it passes. Use .notNull() or isNotNull(...) for strict presence.
Indexes
Do not create indexes on createdAt. In kitcn ORM, createdAt maps to internal _creationTime, and explicit createdAt indexes can fail. Use updatedAt (or another explicit sortable field) for custom indexes.
index()
Add indexes for fields you filter or sort by.
import { convexTable, id, index, text } from 'kitcn/orm';
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull(),
},
(t) => [index('by_author').on(t.authorId)]
);searchIndex()
Search indexes mirror Convex's text search, with a Drizzle-style builder API.
import { convexTable, id, searchIndex, text } from 'kitcn/orm';
export const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users').notNull(),
},
(t) => [searchIndex('by_title').on(t.title).filter(t.authorId)]
);Tip: Use .staged() to stage an index for later activation.
vectorIndex()
Vector indexes mirror Convex's vector search API.
import { convexTable, id, vector, vectorIndex } from 'kitcn/orm';
export const posts = convexTable(
'posts',
{
authorId: id('users').notNull(),
embedding: vector(1536).notNull(),
},
(t) => [
vectorIndex('embedding_vec')
.on(t.embedding)
.dimensions(1536)
.filter(t.authorId),
]
);Drizzle Differences
A few things work differently from SQL-based Drizzle:
- Convex creates
idautomatically (noprimaryKey()builder) - Check constraints are enforced at runtime (ORM mutations only)
- SQL-only features (database-side cascades, triggers, raw SQL) are not applicable