Skip to content

Commit

Permalink
Migrate to kysely for real (probably) (#68)
Browse files Browse the repository at this point in the history
Unfortunately I don't know the precise cause of why the last attempt to
roll this out didn't work. The bot didn't respond at all and it seemed
potentially like it had created a new, empty database. In digging in to
try again, I learned:

- migration tables had not been accounted for in the transition from
Knex -> Kysely
- migrations were not being run on startup
- past migrations were not able to be re-run

Fingers crossed this works this time 🤞
  • Loading branch information
vcarl authored Sep 5, 2024
1 parent b6eb6b0 commit e0d081b
Show file tree
Hide file tree
Showing 29 changed files with 2,034 additions and 535 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ jobs:
--from-literal=DISCORD_APP_ID=${{ secrets.DISCORD_APP_ID }} \
--from-literal=DISCORD_SECRET=${{ secrets.DISCORD_SECRET }} \
--from-literal=DISCORD_HASH=${{ secrets.DISCORD_HASH }} \
--from-literal=DISCORD_TEST_GUILD=${{ secrets.DISCORD_TEST_GUILD }}
--from-literal=DISCORD_TEST_GUILD=${{ secrets.DISCORD_TEST_GUILD }} \
--from-literal=DATABASE_URL=${{ secrets.DATABASE_URL }}
kubectl apply -k .
- name: Set Sentry release
Expand Down
5 changes: 5 additions & 0 deletions .lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
module.exports = {
"**/*.[tj]s?(x)": ["eslint --fix --max-warnings=0", "prettier --check"],
"migrations/*.[tj]s": [
"npm run start:migrate",
"npm run generate:db-types",
"git add app/db.d.ts",
],
};
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install

COPY remix.config.js tailwind.config.js knexfile.ts tsconfig.json .eslint* .prettierignore ./
COPY remix.config.js tailwind.config.js kysely.config.ts tsconfig.json .eslint* .prettierignore ./
COPY app ./app

RUN npm run build
Expand All @@ -22,7 +22,7 @@ RUN npm prune --production
COPY --from=build /app/build ./build
COPY --from=build /app/public ./public

COPY knexfile.ts ./
COPY kysely.config.ts ./
COPY migrations ./migrations

CMD ["npm", "run", "start"]
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Jobs bot
# Mod bot

This code powers the Euno bot on Discord.

Initial setup

Expand All @@ -11,7 +13,7 @@ yarn dev
Uses:

- [Remix](https://remix.run/docs/en/v1)
- [Knex](https://knexjs.org/)
- [Kysely](https://kysely.dev/)
- SQLite3 (with [better-sqlite3](http://npmjs.com/package/better-sqlite3))

Deployed with:
Expand All @@ -22,8 +24,9 @@ Deployed with:

Details:

migrations with `yarn migrate:latest`. latest installed version is tracked in 2 tables of the sqlite data. schema changes must be done cautiously, should have a set up/tear down function tested before merging.
migrations with `npm run start:migrate`. latest installed version is tracked in 2 tables of the sqlite data. schema changes must be done cautiously, should have a set up/tear down function tested before merging. Start a new migration with `npx kysely migrate:make <name>`

seed data is stored in seeds/
Migrations are stored in `migrations/`
Generated DB types are stored in `app/db.d.ts` and generated automatically in a precommit hook.

auth system is simple delegated auth to discord. accounts are created if not found locally, no passwords or secondary confirmation atm
2 changes: 1 addition & 1 deletion app/components/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function Login({ errors }: { errors?: { [k: string]: string } }) {
<input type="hidden" name="redirectTo" value={redirectTo} />
<button
type="submit"
className="w-full rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Log in with Discord
</button>
Expand Down
29 changes: 29 additions & 0 deletions app/db.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ColumnType } from "kysely";

export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;

export interface Guilds {
id: string | null;
settings: string | null;
}

export interface Sessions {
data: string | null;
expires: string | null;
id: string | null;
}

export interface Users {
authProvider: Generated<string | null>;
email: string | null;
externalId: string;
id: string;
}

export interface DB {
guilds: Guilds;
sessions: Sessions;
users: Users;
}
30 changes: 17 additions & 13 deletions app/db.server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import knex from "knex";
import knexfile from "~/../knexfile";
import SQLite from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import type { DB } from "./db";
import { databaseUrl } from "./helpers/env";

export { SqliteError } from "better-sqlite3";

const environment = process.env.NODE_ENV || ("development" as const);
// @ts-nocheck
const config: {
client: string;
connection: {
filename: string;
};
useNullAsDefault: boolean;
} = (knexfile as any)[environment];

export default knex(config);
console.log(`Connecting to database at ${databaseUrl}`);

export const dialect = new SqliteDialect({
database: new SQLite(databaseUrl),
});

const db = new Kysely<DB>({
dialect,
});

export default db;
export type { DB };
3 changes: 2 additions & 1 deletion app/discord/client.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GatewayIntentBits, Client, Partials, ActivityType } from "discord.js";
import { ReacordDiscordJs } from "reacord";
import { discordToken } from "~/helpers/env";

export const client = new Client({
intents: [
Expand All @@ -19,7 +20,7 @@ export const reacord = new ReacordDiscordJs(client);
export const login = () => {
console.log("INI", "Bootstrap starting…");
client
.login(process.env.DISCORD_HASH || "")
.login(discordToken)
.then(async () => {
console.log("INI", "Bootstrap complete");

Expand Down
19 changes: 16 additions & 3 deletions app/helpers/env.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
const enum ENVIONMENTS {
const enum ENVIRONMENTS {
production = "production",
test = "test",
local = "",
}

let ok = true;
const getEnv = (key: string, optional = false) => {
const value = process.env[key];
if (process.env.NODE_ENV === "test") {
return "";
}
if (!value && !optional) {
console.log(`Add a ${key} value to .env`);
ok = false;
Expand All @@ -13,10 +18,18 @@ const getEnv = (key: string, optional = false) => {
return value ?? "";
};

export const isProd = () => process.env.ENVIRONMENT === ENVIONMENTS.production;
console.log("Running as", isProd() ? "PRODUCTION" : "TEST", "environment");
export const isProd = () => process.env.NODE_ENV === ENVIRONMENTS.production;
console.log(
"Running as",
isProd() ? "PRODUCTION" : "TEST",
`environment: '${process.env.NODE_ENV}'`,
);

export const databaseUrl = getEnv("DATABASE_URL");
export const sessionSecret = getEnv("SESSION_SECRET");

export const applicationKey = getEnv("DISCORD_PUBLIC_KEY");
export const discordSecret = getEnv("DISCORD_SECRET");
export const applicationId = getEnv("DISCORD_APP_ID");
export const discordToken = getEnv("DISCORD_HASH");
export const testGuild = getEnv("DISCORD_TEST_GUILD");
Expand Down
16 changes: 14 additions & 2 deletions app/helpers/modLog.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {
Message,
TextChannel,
MessageCreateOptions,
User,
ClientUser,
} from "discord.js";
import { ChannelType } from "discord.js";
import TTLCache from "@isaacs/ttlcache";

import { fetchSettings, SETTINGS } from "~/models/guilds.server";
Expand Down Expand Up @@ -113,7 +113,15 @@ export const reportUser = async ({
} else {
// If this is new, send a new message
const { modLog: modLogId } = await fetchSettings(guild, [SETTINGS.modLog]);
const modLog = (await guild.channels.fetch(modLogId)) as TextChannel;
const modLog = await guild.channels.fetch(modLogId);
if (!modLog) {
throw new Error("Channel configured for use as mod log not found");
}
if (modLog.type !== ChannelType.GuildText) {
throw new Error(
"Invalid channel configured for use as mod log, must be guild text",
);
}
const newLogs: Report[] = [{ message, reason, staff }];

const logBody = await constructLog({
Expand Down Expand Up @@ -153,6 +161,10 @@ const constructLog = async ({
SETTINGS.moderator,
]);

if (!moderator) {
throw new Error("No role configured to be used as moderator");
}

const preface = `<@${lastReport.message.author.id}> (${
lastReport.message.author.username
}) warned ${previousWarnings.size + 1} times recently, posted in ${
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/modResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const humanReadableResolutions = {
[resolutions.kick]: "Kick",
[resolutions.ban]: "Ban",
} as const;
export type Resolution = typeof resolutions[keyof typeof resolutions];
export type Resolution = (typeof resolutions)[keyof typeof resolutions];

export const useVotes = () => {
const [votes, setVotes] = useState({} as Record<Resolution, string[]>);
Expand Down
59 changes: 36 additions & 23 deletions app/models/guilds.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import type { Guild as DiscordGuild } from "discord.js";
import knex, { SqliteError } from "~/db.server";
import db, { SqliteError } from "~/db.server";
import type { DB } from "~/db.server";

type jsonString = string;
export interface Guild {
id: string;
settings: jsonString;
}
export type Guild = DB["guilds"];

export const SETTINGS = {
modLog: "modLog",
Expand All @@ -22,14 +19,21 @@ interface SettingsRecord {
}

export const fetchGuild = async (guild: DiscordGuild) => {
return await knex<Guild>("guilds").where({ id: guild.id }).first();
return await db
.selectFrom("guilds")
.where("id", "=", guild.id)
.executeTakeFirst();
};

export const registerGuild = async (guild: DiscordGuild) => {
try {
await knex("guilds").insert({
id: guild.id,
settings: {},
});
await db
.insertInto("guilds")
.values({
id: guild.id,
settings: JSON.stringify({}),
})
.execute();
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
// do nothing
Expand All @@ -43,21 +47,30 @@ export const setSettings = async (
guild: DiscordGuild,
settings: SettingsRecord,
) => {
await Promise.all(
Object.entries(settings).map(([key, value]) =>
knex("guilds")
.update({ settings: knex.jsonSet("settings", `$.${key}`, value) })
.where({ id: guild.id }),
),
);
await db
.updateTable("guilds")
.set("settings", (eb) =>
eb.fn("json_patch", ["settings", eb.val(JSON.stringify(settings))]),
)
.where("id", "=", guild.id)
.execute();
};

export const fetchSettings = async <T extends keyof typeof SETTINGS>(
guild: DiscordGuild,
keys: T[],
): Promise<Pick<SettingsRecord, typeof keys[number]>> => {
return await knex("guilds")
.where({ id: guild.id })
.select(knex.jsonExtract(keys.map((k) => ["settings", `$.${k}`, k])))
.first();
) => {
return (
(await db
.selectFrom("guilds")
// @ts-expect-error This is broken because of a migration from knex and
// old/bad use of jsonb for storing settings. The type is guaranteed here
// not by the codegen
.select<DB, "guilds", SettingsRecord>((eb) =>
keys.map((k) => eb.ref("settings", "->").key(k).as(k)),
)
.where("id", "=", guild.id)
// This cast is also evidence of the pattern being broken
.executeTakeFirstOrThrow()) as Pick<SettingsRecord, T>
);
};
Loading

0 comments on commit e0d081b

Please sign in to comment.