Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Reference and source