Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions app/Database.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { Context, Effect, Layer } from "effect";
import { Context, Effect, Layer, ManagedRuntime } from "effect";

import { SqlClient } from "@effect/sql";
import * as Sqlite from "@effect/sql-kysely/Sqlite";
import { SqliteClient } from "@effect/sql-sqlite-node";
import { ResultLengthMismatch, SqlError } from "@effect/sql/SqlError";

import type { DB } from "./db";
import { DatabaseCorruptionError } from "./effects/errors";
import { DatabaseCorruptionError, NotFoundError } from "./effects/errors";
import { databaseUrl, emergencyWebhook } from "./helpers/env.server";
import { log } from "./helpers/observability";
import { scheduleTask } from "./helpers/schedule";

// Re-export SQL errors for consumers
// Re-export SQL errors and DB type for consumers
export { SqlError, ResultLengthMismatch };
export type { DB };

// Type alias for the effectified Kysely instance
export type EffectKysely = Sqlite.EffectKysely<DB>;
Expand All @@ -39,6 +40,68 @@ export const DatabaseLayer = Layer.mergeAll(SqliteLive, KyselyLive);

log("info", "Database", `Database configured at ${databaseUrl}`);

// --- ManagedRuntime (single connection for the process lifetime) ---

// ManagedRuntime keeps the DatabaseLayer scope alive for the process lifetime.
// Unlike Effect.runSync which closes the scope (and thus the SQLite connection)
// after execution, ManagedRuntime holds the scope open until explicit disposal.
export const runtime = ManagedRuntime.make(DatabaseLayer);

// The context type provided by the ManagedRuntime. Use this for typing functions
// that accept effects which need database access.
export type RuntimeContext = ManagedRuntime.ManagedRuntime.Context<
typeof runtime
>;

// Extract the EffectKysely instance synchronously.
// The connection stays open because the runtime manages the layer's lifecycle.
export const db: EffectKysely = runtime.runSync(DatabaseService);

// Set busy_timeout so queries wait for locks instead of failing immediately
runtime.runSync(
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql.unsafe("PRAGMA busy_timeout = 5000");
}),
);

/** Checkpoint WAL to main database and dispose the runtime. Call on process shutdown. */
export function shutdownDatabase() {
try {
runtime.runSync(
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql.unsafe("PRAGMA wal_checkpoint(TRUNCATE)");
}),
);
} catch (e) {
console.error("Failed to checkpoint WAL on shutdown", e);
}
}

// --- Bridge functions for legacy async/await code ---

// Convenience helpers for legacy async/await code that needs to run
// EffectKysely query builders as Promises.
export const run = <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> =>
Effect.runPromise(effect);

export const runTakeFirst = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A | undefined> =>
Effect.runPromise(Effect.map(effect, (rows) => rows[0]));

export const runTakeFirstOrThrow = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A> =>
Effect.runPromise(
Effect.flatMap(effect, (rows) =>
rows[0] !== undefined
? Effect.succeed(rows[0])
: Effect.fail(new NotFoundError({ resource: "db record", id: "" })),
),
);

// --- Integrity Check ---

const TWELVE_HOURS = 12 * 60 * 60 * 1000;
Expand Down Expand Up @@ -103,9 +166,7 @@ export const runIntegrityCheck = Effect.gen(function* () {
/** Start the twice-daily integrity check scheduler */
export function startIntegrityCheck() {
return scheduleTask("IntegrityCheck", TWELVE_HOURS, () => {
Effect.runPromise(
runIntegrityCheck.pipe(Effect.provide(DatabaseLayer)),
).catch(() => {
runtime.runPromise(runIntegrityCheck).catch(() => {
// Errors already logged and webhook sent
});
});
Expand Down
2 changes: 0 additions & 2 deletions app/commands/escalate/escalationResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from "discord.js";
import { Effect } from "effect";

import { DatabaseLayer } from "#~/Database.ts";
import {
editMessage,
fetchChannelFromClient,
Expand Down Expand Up @@ -186,7 +185,6 @@ export const checkPendingEscalationsEffect = (client: Client) =>
// TODO: In the future, we should have a smarter fetch that manages that
const results = yield* Effect.forEach(due, (escalation) =>
processEscalationEffect(client, escalation).pipe(
Effect.provide(DatabaseLayer),
Effect.catchAll((error) =>
logEffect(
"error",
Expand Down
14 changes: 4 additions & 10 deletions app/commands/escalate/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import {
MessageFlags,
type MessageComponentInteraction,
} from "discord.js";
import { Effect, Layer } from "effect";
import { Effect } from "effect";

import { DatabaseLayer } from "#~/Database.ts";
import {
editMessage,
interactionDeferReply,
Expand Down Expand Up @@ -96,7 +95,7 @@ const vote =
resolution,
},
}),
Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)),
Effect.provide(EscalationServiceLive),
Effect.catchTag("NotAuthorizedError", () =>
interactionReply(interaction, {
content: "Only moderators can vote on escalations.",
Expand Down Expand Up @@ -147,7 +146,7 @@ ${buildVotesListContent(result.tally)}`,
Effect.withSpan("escalation-expedite", {
attributes: { guildId: interaction.guildId, userId: interaction.user.id },
}),
Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)),
Effect.provide(EscalationServiceLive),
Effect.catchTag("NotAuthorizedError", () =>
interactionFollowUp(interaction, {
content: "Only moderators can expedite resolutions.",
Expand Down Expand Up @@ -215,7 +214,7 @@ const escalate = (interaction: MessageComponentInteraction) =>
Effect.withSpan("escalation-escalate", {
attributes: { guildId: interaction.guildId, userId: interaction.user.id },
}),
Effect.provide(Layer.mergeAll(DatabaseLayer, EscalationServiceLive)),
Effect.provide(EscalationServiceLive),
Effect.catchTag("NotFoundError", () =>
interactionEditReply(interaction, {
content: "Failed to re-escalate, couldn't find escalation",
Expand Down Expand Up @@ -251,7 +250,6 @@ export const EscalationHandlers = {
userId: interaction.user.id,
},
}),
Effect.provide(DatabaseLayer),
Effect.catchTag("NotAuthorizedError", () =>
interactionEditReply(interaction, {
content: "Insufficient permissions",
Expand All @@ -277,7 +275,6 @@ export const EscalationHandlers = {
`<@${reportedUserId}> kicked by ${result.actionBy}`,
);
}).pipe(
Effect.provide(DatabaseLayer),
Effect.catchTag("NotAuthorizedError", () =>
interactionReply(interaction, {
content: "Insufficient permissions",
Expand Down Expand Up @@ -318,7 +315,6 @@ export const EscalationHandlers = {
userId: interaction.user.id,
},
}),
Effect.provide(DatabaseLayer),
Effect.catchTag("NotAuthorizedError", () =>
interactionReply(interaction, {
content: "Insufficient permissions",
Expand Down Expand Up @@ -353,7 +349,6 @@ export const EscalationHandlers = {
userId: interaction.user.id,
},
}),
Effect.provide(DatabaseLayer),
Effect.catchTag("NotAuthorizedError", () =>
interactionReply(interaction, {
content: "Insufficient permissions",
Expand Down Expand Up @@ -388,7 +383,6 @@ export const EscalationHandlers = {
userId: interaction.user.id,
},
}),
Effect.provide(DatabaseLayer),
Effect.catchTag("NotAuthorizedError", () =>
interactionReply(interaction, {
content: "Insufficient permissions",
Expand Down
4 changes: 2 additions & 2 deletions app/commands/escalate/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Guild } from "discord.js";
import { Context, Effect, Layer } from "effect";
import type { Selectable } from "kysely";

import { DatabaseLayer, DatabaseService, type SqlError } from "#~/Database";
import { DatabaseService, type SqlError } from "#~/Database";
import type { DB } from "#~/db";
import { fetchMember } from "#~/effects/discordSdk.ts";
import {
Expand Down Expand Up @@ -372,4 +372,4 @@ export const EscalationServiceLive = Layer.effect(
),
};
}),
).pipe(Layer.provide(DatabaseLayer));
);
2 changes: 0 additions & 2 deletions app/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
import { Effect } from "effect";

import { logUserMessage } from "#~/commands/report/userLog.ts";
import { DatabaseLayer } from "#~/Database.ts";
import {
interactionDeferReply,
interactionEditReply,
Expand Down Expand Up @@ -47,7 +46,6 @@ export const Command = {
content: "This message has been reported anonymously",
});
}).pipe(
Effect.provide(DatabaseLayer),
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* logEffect("error", "Commands", "Report command failed", {
Expand Down
18 changes: 5 additions & 13 deletions app/commands/report/modActionLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
import { Effect } from "effect";

import { logAutomod } from "#~/commands/report/automodLog.ts";
import { DatabaseLayer } from "#~/Database.ts";
import { fetchUser } from "#~/effects/discordSdk.ts";
import { logEffect } from "#~/effects/observability.ts";
import { runEffect } from "#~/effects/runtime.ts";
Expand Down Expand Up @@ -357,23 +356,16 @@ const memberUpdateEffect = (
}).pipe(Effect.withSpan("handleMemberUpdate"));

// Thin async wrappers that execute the Effects
const handleBanAdd = (ban: GuildBan) =>
runEffect(banAddEffect(ban).pipe(Effect.provide(DatabaseLayer)));
const handleBanRemove = (ban: GuildBan) =>
runEffect(banRemoveEffect(ban).pipe(Effect.provide(DatabaseLayer)));
const handleBanAdd = (ban: GuildBan) => runEffect(banAddEffect(ban));
const handleBanRemove = (ban: GuildBan) => runEffect(banRemoveEffect(ban));
const handleMemberRemove = (member: GuildMember | PartialGuildMember) =>
runEffect(memberRemoveEffect(member).pipe(Effect.provide(DatabaseLayer)));
runEffect(memberRemoveEffect(member));
const handleAutomodAction = (execution: AutoModerationActionExecution) =>
runEffect(automodActionEffect(execution).pipe(Effect.provide(DatabaseLayer)));
runEffect(automodActionEffect(execution));
const handleMemberUpdate = (
oldMember: GuildMember | PartialGuildMember,
newMember: GuildMember | PartialGuildMember,
) =>
runEffect(
memberUpdateEffect(oldMember, newMember).pipe(
Effect.provide(DatabaseLayer),
),
);
) => runEffect(memberUpdateEffect(oldMember, newMember));

export default async (bot: Client) => {
bot.on(Events.GuildBanAdd, handleBanAdd);
Expand Down
14 changes: 2 additions & 12 deletions app/commands/report/userLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import {
} from "discord.js";
import { Effect } from "effect";

import {
DatabaseLayer,
type DatabaseService,
type SqlError,
} from "#~/Database";
import { type DatabaseService, type SqlError } from "#~/Database";
import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk.ts";
import { DiscordApiError, type NotFoundError } from "#~/effects/errors";
import { logEffect } from "#~/effects/observability";
Expand Down Expand Up @@ -262,10 +258,4 @@ export const logUserMessageLegacy = ({
staff,
}: Omit<Report, "date">): Promise<
Reported & { allReportedMessages: Report[] }
> =>
runEffect(
Effect.provide(
logUserMessage({ reason, message, extra, staff }),
DatabaseLayer,
),
);
> => runEffect(logUserMessage({ reason, message, extra, staff }));
7 changes: 4 additions & 3 deletions app/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export const Command = {
return;
}

yield* Effect.tryPromise(() => registerGuild(interaction.guildId!));
yield* Effect.tryPromise(() =>
registerGuild(interaction.guildId!).catch(console.error),
);

const role = interaction.options.getRole("moderator");
const channel = interaction.options.getChannel("mod-log-channel");
Expand Down Expand Up @@ -96,8 +98,7 @@ export const Command = {
yield* logEffect("error", "Commands", "Setup command failed", {
guildId: interaction.guildId,
userId: interaction.user.id,
error: err.message,
stack: err.stack,
error: err,
});

commandStats.commandFailed(interaction, "setup", err.message);
Expand Down
26 changes: 11 additions & 15 deletions app/commands/setupHoneypot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "discord.js";
import { Effect } from "effect";

import db from "#~/db.server.js";
import { DatabaseService } from "#~/Database.ts";
import { interactionReply, sendMessage } from "#~/effects/discordSdk.ts";
import { logEffect } from "#~/effects/observability.ts";
import type { SlashCommand } from "#~/helpers/discord.js";
Expand Down Expand Up @@ -66,18 +66,16 @@ export const Command = [
}

const castedChannel = honeypotChannel as TextChannel;
const result = yield* Effect.tryPromise(() =>
db
.insertInto("honeypot_config")
.values({
guild_id: interaction.guildId!,
channel_id: honeypotChannel.id,
})
.onConflict((c) => c.doNothing())
.execute(),
);
const db = yield* DatabaseService;
const result = yield* db
.insertInto("honeypot_config")
.values({
guild_id: interaction.guildId,
channel_id: honeypotChannel.id,
})
.onConflict((c) => c.doNothing());

if ((result[0].numInsertedOrUpdatedRows ?? 0) > 0) {
if ((result[0]?.numInsertedOrUpdatedRows ?? 0) > 0) {
yield* sendMessage(castedChannel, messageText);
featureStats.honeypotSetup(
interaction.guildId,
Expand All @@ -97,9 +95,7 @@ export const Command = [
"error",
"HoneypotSetup",
"Error during honeypot action",
{
error: String(error),
},
{ error },
);

yield* interactionReply(interaction, {
Expand Down
Loading