Skip to content

Kysely services

Write a service class for each Postgres table with the kysely plugin, and read those services from any handler.

If every handler that looks up a user writes its own Kysely query, the same query ends up in several files. Changing how you find a user then means finding every copy.

A service keeps one table's queries in one class. Extend KyselyService, pass the table name as its type argument, and decorate the class with a key. Handlers then call its methods through that key.

src/postgres/Users.ts
import {
    KyselyService,
    RegisterKyselyService
} from '@seedcord/plugin-kysely-postgres';


@RegisterKyselyService('users')
export class Users extends KyselyService<'users'> {
    public findByDiscordId(discordId: string) {
        return this.db
            .selectFrom(this.table)
            .selectAll()
            .where('discord_id', '=', discordId)
            .executeTakeFirst();
    }
}

Here's what each part of Users.ts does.

  • @RegisterKyselyService('users') gives the class its key. Your handlers read the service from services under that key, and the plugin also uses it as the table name unless you pass another one.
  • KyselyService<'users'> is the base class, and its type argument is a table from your schema. this.table holds that name, so selectFrom(this.table) checks discord_id against the users columns.
  • this.db returns the plugin's connection, so every service queries through the same pool. It can query every table in your schema, so a method can join users with another table.
  • findByDiscordId keeps the lookup inside the class. Your handlers call that method on services.users, so none of them writes the query. services.users gets its type once codegen has run.

You never construct a service

The plugin builds your services itself, after it has connected and run your startup migrations, so this.db has a working connection by the time any method runs. It imports every code file under the dir you gave the kysely plugin, including subfolders, in order of path. That covers .ts, .tsx, .mts, .js, .jsx, and .mjs, and skips type declarations, dotfiles, dot-folders, and node_modules. For each exported class that extends KyselyService and carries the decorator, it constructs the class. That constructor call adds the instance to the plugin's services map under its key.

When a service reads undefined

Warning

If a class isn't exported or has no @RegisterKyselyService, the plugin skips it without throwing or logging anything, and services.users reads undefined. Codegen skips the class too, so once it has run, services.users fails to compile.

The key is also the table name

A service registered as 'users' reads and writes the users table, because the plugin uses the key as the table name. That fits most services. When the table's name isn't one you want as a key, like an app_users table named with a prefix, pass table.

declare module '@seedcord/plugin-kysely-postgres' {
    // write this in src/database.ts. it's only here for the example
    interface KyselyDatabase {
        schema: { app_users: UsersTable };
    }
}

@RegisterKyselyService('users', { table: 'app_users' })
export class Users extends KyselyService<'app_users'> {}

Users queries the app_users table, while handlers use it as services.users. The two names come from two places:

  • 'users' is the service's key. Codegen writes it into your types along with the class, as Typing the registry shows.
  • 'app_users' is a table in the schema you declared on KyselyDatabase, which Your schema covers. table only accepts a table from that schema, and the decorator checks the class's type argument against it.

Your editor also reads that schema to suggest table names for table. With a bans table added, it offers both:

declare module '@seedcord/plugin-kysely-postgres' {
    // write this in src/database.ts. it's only here for the example
    interface KyselyDatabase {
        schema: { app_users: UsersTable; bans: BansTable };
    }
}

RegisterKyselyService('users', { table: '
  • app_users
  • bans

The decorator checks that table matches the class's type argument, so TypeScript reports an error if you pass { table: 'app_users' } for a KyselyService<'bans'>. When you leave table out, the decorator uses the key as the table name. users isn't a table in that schema, so Users needs table, and TypeScript reports an error on the decorator without it.

Typing the registry

The plugin finds your services by importing dir when the bot starts. TypeScript only reads your source, so without codegen each service needs a hand-written declare module entry, and every rename means editing it.

seedcord codegen imports dir the same way and writes each key it finds into src/seedcord-gen.ts. services.users is then typed as your Users service, and the decorator only accepts the keys codegen found.

RegisterKyselyService('
  • users
  • guilds

With a users and a guilds service in your dir, the decorator offers both. Before codegen first runs, the decorator accepts any key, and TypeScript types every entry on services as object.

Warning

If you add a service or change its key, the decorator rejects the new key until codegen runs again. The generated file still lists the keys from the last run, so run seedcord codegen.

Reading a service

A handler gets a service from the plugin's services map, under the key you declared.

hover for typestap for types, arrow keys walk the tokens
const 
const found:
    | {
          id: number;
          discord_id: string;
          username: string;
          streak: number;
      }
    | undefined
found
=
await this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.core: Corecore.Core.db: KyselyPostgresdb.KyselyPostgres.services: KyselyServicesservices.users: Usersusers.
Users.findByDiscordId(discordId: string): Promise<
    | {
          id: number;
          discord_id: string;
          username: string;
          streak: number;
      }
    | undefined
>
findByDiscordId
(
this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.event: ChatInputCommandInteraction<"cached">event.BaseInteraction<"cached">.user: Useruser.User.id: stringid ); await this.
RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(
    response:
        | string
        | ReplyResponse<
              | BufferResolvable
              | Stream
              | JSONEncodable<APIAttachment>
              | Attachment
              | AttachmentBuilder
              | AttachmentPayload
          >,
    opts?: SendOpts
): Promise<SentMessage>
reply
(
const found:
    | {
          id: number;
          discord_id: string;
          username: string;
          streak: number;
      }
    | undefined
found
? `Streak ${
var String: StringConstructor;
(value?: any) => string
String
(
const found: {
    id: number;
    discord_id: string;
    username: string;
    streak: number;
}
found
.streak: numberstreak)}.` : 'No record.'
);

findByDiscordId resolves to undefined when no row matches, so the handler checks found before it reads streak.

If you read services before the plugin's init() has connected and loaded every class, it throws PluginServicesNotReady. Your handlers never read it that early, though, because they only start receiving interactions once init() has finished.