Custom models
Add typed, named queries with defineModel, use the injected Drizzle query builder, and preserve transaction and authorization boundaries.
Add a model when a table needs a named operation beyond the built-in CRUD methods. A model extends the table client with named functions; it does not create a new table, require a class, or replace the owning business service. If the six base methods are sufficient, do not create an empty model file.
Only when selecting the MySQL model entrypoint, add its declaration requirement:
From this guide’s application/package root, install only its selected requirements:
bun add --dev 'mysql2@>=2.0.0'
Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.
Define an extension beside the schema
/** Extends the native users model with email-domain selection and an update that conceals missing or inactive rows. */
import { defineModel, type UpdateArgs } from "@relkit/drizzle/sqlite";
import { eq, like } from "drizzle-orm";
import { users } from "../schema/index.js";
export default defineModel({
table: users,
extend: {
byEmailDomain: ({ table, database }, domain: string) =>
database
.select()
.from(table)
.where(like(table.email, `%@${domain}`)),
findOneAndUpdate: async ({ table, database }, args: UpdateArgs<typeof users>) => {
const id = args.where.id;
if (id === undefined) return null;
const [existing] = await database.select().from(table).where(eq(table.id, id));
if (existing === undefined || existing.active === false) return null;
const [updated] = await database
.update(table)
.set(args.data)
.where(eq(table.id, id))
.returning();
return updated ?? null;
},
},
});table must be the exact table object exported by the service’s schema. extend must contain at least
one function. Names such as byEmailDomain and findOneAndUpdate become methods on the Users table client.
The first extension argument is injected: { table, database }. Additional arguments form the consumer
API, and return types are preserved. Use this parameter instead of class state such as this.drizzle
or this.table.
Register and call the model
Import the model into src/services/database/service.ts and add models: { users: usersModel } to
defineDrizzleService. The registration key must match the schema export key, and the model must use
that same table object. A file under models/ is not automatically registered just because it exists.
After bun run check refreshes context types, functions call the method without supplying the injected
first argument:
/** Lists validated user fields through the database model's email-domain extension. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
export default defineFunction({
input: z.object({ domain: z.string() }),
output: z.array(z.object({ id: z.number(), email: z.string(), active: z.boolean() })),
handler: ({ domain }, context) => context.database.users.byEmailDomain(domain),
});Keep business entry points in their owning domains and expose them through Service. Do not make routes or other domains import private table files just to construct a query.
Use native Drizzle where it helps
The injected database provides the dialect’s query builder for joins, aggregations, projections, and
native mutations. It is not the portable context.database, so do not expect database.users.findOne
inside the extension. The public extension type intentionally omits Drizzle’s relational query API;
use the supported query-builder surface rather than assuming relation-loading types are present.
An extension called through the context supplied by transaction receives the transaction-bound client.
Use that injected client, not a separately imported connection. See Transactions.
Make custom behavior explicit
Custom methods bypass the portable CRUD implementation and its operation overrides. Add the validation,
tenant restrictions, limits, and result checks the query needs. You cannot use an extension named
findOne, findMany, insert, update, upsert, or delete; use
operation overrides to customize those names.
The sample byEmailDomain uses Drizzle’s like() expression: values are parameterized, but % and _
in the supplied domain still act as pattern wildcards. It also has no pagination. Validate or escape
patterns and add an explicit limit before using it as a public search feature. For native statements
beyond the query builder, refer to Drizzle’s documentation.
Likewise, findOneAndUpdate performs a read followed by an update; its name does not make the sequence
atomic. Protect concurrent state changes with an appropriate database operation and test it. The
model test verifies the demonstration behavior without implying stronger guarantees.