Kysely services
Write a service class for each Postgres table with the kysely plugin. Covers its table and key, overriding the table name, typing the services map, and reading a service from a 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.
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();
}
}
declare module '@seedcord/plugin-kysely-postgres' {
interface KyselyServices {
users: Users;
}
}Here's what each part of Users.ts does.
@RegisterKyselyService('users')gives the class its key. Your handlers read the service fromservicesunder 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.tableholds that name, soselectFrom(this.table)checksdiscord_idagainst theuserscolumns.this.dbreturns the plugin'sconnection, so every service queries through the same pool. It can query every table in your schema, so a method can joinuserswith another table.findByDiscordIdkeeps the lookup inside the class. A handler calls that method and never writes the query itself.- The
declare moduleblock addsusersto the keys the decorator accepts. Typing the registry explains why you write it.
You never construct a service
The plugin builds your services itself, after it has connected and run your migrations, so this.db has a working connection by the time any method runs. It imports every .ts and .js file under the dir you gave the kysely plugin, including subfolders. 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.
Warning
If a class isn't exported or has no @RegisterKyselyService, the plugin skips it without throwing or logging anything. services.users then reads undefined even though TypeScript types it as Users.
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 };
}
interface KyselyServices {
users: Users;
}
}
@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 the two interfaces in that declare module block:
'users'is a key inKyselyServices. The decorator's first argument only accepts keys declared there.'app_users'is a table in theschemayou declared onKyselyDatabase, which Your schema covers.tableonly accepts a table from that schema, and the decorator checks the class's type argument against it.
That schema is also where your editor gets the table names it suggests 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 };
}
interface KyselyServices {
users: Users;
}
}
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
KyselyServices starts empty, so the decorator doesn't accept any key until you declare one. The declare module block in src/postgres/Users.ts above adds users. The decorator then takes 'users' and checks that the class matches Users, the type you declared for that key. If you pass 'guilds' to the decorator before adding a guilds entry, TypeScript reports an error.
declare module '@seedcord/plugin-kysely-postgres' {
interface KyselyServices {
users: Users;
guilds: Guilds;
}
}
RegisterKyselyService('- users
- guilds
The decorator offers the two keys that declare module block adds to KyselyServices, users and guilds.
You add each entry by hand when you write a new service, since codegen doesn't write this block. It only writes the Core block from Typing a plugin.
Reading a service
A handler gets a service from the plugin's services map, under the key you declared.
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.KyselyServices.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, migrated, and loaded every class, it throws PluginKyselyServicesNotReady. Your handlers never read it that early, though, because they only start receiving interactions once init() has finished.