diff --git a/app/Database.ts b/app/Database.ts index d53db21f..0f80a44d 100644 --- a/app/Database.ts +++ b/app/Database.ts @@ -1,4 +1,4 @@ -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"; @@ -6,13 +6,14 @@ 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; @@ -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 = (effect: Effect.Effect): Promise => + Effect.runPromise(effect); + +export const runTakeFirst = ( + effect: Effect.Effect, +): Promise => + Effect.runPromise(Effect.map(effect, (rows) => rows[0])); + +export const runTakeFirstOrThrow = ( + effect: Effect.Effect, +): Promise => + 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; @@ -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 }); }); diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index f366e965..94292b10 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -8,7 +8,6 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { DatabaseLayer } from "#~/Database.ts"; import { editMessage, fetchChannelFromClient, @@ -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", diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index 12c9c00f..34d9f035 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -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, @@ -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.", @@ -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.", @@ -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", @@ -251,7 +250,6 @@ export const EscalationHandlers = { userId: interaction.user.id, }, }), - Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionEditReply(interaction, { content: "Insufficient permissions", @@ -277,7 +275,6 @@ export const EscalationHandlers = { `<@${reportedUserId}> kicked by ${result.actionBy}`, ); }).pipe( - Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { content: "Insufficient permissions", @@ -318,7 +315,6 @@ export const EscalationHandlers = { userId: interaction.user.id, }, }), - Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { content: "Insufficient permissions", @@ -353,7 +349,6 @@ export const EscalationHandlers = { userId: interaction.user.id, }, }), - Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { content: "Insufficient permissions", @@ -388,7 +383,6 @@ export const EscalationHandlers = { userId: interaction.user.id, }, }), - Effect.provide(DatabaseLayer), Effect.catchTag("NotAuthorizedError", () => interactionReply(interaction, { content: "Insufficient permissions", diff --git a/app/commands/escalate/service.ts b/app/commands/escalate/service.ts index e1848520..b741d50c 100644 --- a/app/commands/escalate/service.ts +++ b/app/commands/escalate/service.ts @@ -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 { @@ -372,4 +372,4 @@ export const EscalationServiceLive = Layer.effect( ), }; }), -).pipe(Layer.provide(DatabaseLayer)); +); diff --git a/app/commands/report.ts b/app/commands/report.ts index e802e023..d7b01376 100644 --- a/app/commands/report.ts +++ b/app/commands/report.ts @@ -7,7 +7,6 @@ import { import { Effect } from "effect"; import { logUserMessage } from "#~/commands/report/userLog.ts"; -import { DatabaseLayer } from "#~/Database.ts"; import { interactionDeferReply, interactionEditReply, @@ -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", { diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index c2ea781d..f7859706 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -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"; @@ -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); diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index dffcd8e8..96e224d0 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -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"; @@ -262,10 +258,4 @@ export const logUserMessageLegacy = ({ staff, }: Omit): Promise< Reported & { allReportedMessages: Report[] } -> => - runEffect( - Effect.provide( - logUserMessage({ reason, message, extra, staff }), - DatabaseLayer, - ), - ); +> => runEffect(logUserMessage({ reason, message, extra, staff })); diff --git a/app/commands/setup.ts b/app/commands/setup.ts index 206cd112..5f9fd60d 100644 --- a/app/commands/setup.ts +++ b/app/commands/setup.ts @@ -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"); @@ -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); diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index 9ad03240..cebb884f 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -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"; @@ -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, @@ -97,9 +95,7 @@ export const Command = [ "error", "HoneypotSetup", "Error during honeypot action", - { - error: String(error), - }, + { error }, ); yield* interactionReply(interaction, { diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 28c70f40..8873e765 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -6,7 +6,7 @@ import { } from "discord.js"; import { Effect } from "effect"; -import db from "#~/db.server.js"; +import { DatabaseService } from "#~/Database.ts"; import { interactionReply } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; import type { SlashCommand } from "#~/helpers/discord"; @@ -71,26 +71,24 @@ export const Command = { } // Upsert: update if exists, insert if not - yield* Effect.tryPromise(() => - db - .insertInto("reactji_channeler_config") - .values({ - id: randomUUID(), - guild_id: guildId, + const db = yield* DatabaseService; + yield* db + .insertInto("reactji_channeler_config") + .values({ + id: randomUUID(), + guild_id: guildId, + channel_id: channelId, + emoji, + configured_by_id: configuredById, + threshold, + }) + .onConflict((oc) => + oc.columns(["guild_id", "emoji"]).doUpdateSet({ channel_id: channelId, - emoji, configured_by_id: configuredById, threshold, - }) - .onConflict((oc) => - oc.columns(["guild_id", "emoji"]).doUpdateSet({ - channel_id: channelId, - configured_by_id: configuredById, - threshold, - }), - ) - .execute(), - ); + }), + ); featureStats.reactjiChannelSetup( guildId, @@ -111,7 +109,7 @@ export const Command = { "error", "Commands", "Error configuring reactji channeler", - { error: String(error) }, + { error }, ); yield* interactionReply(interaction, { diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index bc5b0567..edf87347 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -15,8 +15,7 @@ import { } from "discord.js"; import { Effect } from "effect"; -import { DatabaseLayer } from "#~/Database.ts"; -import db from "#~/db.server.js"; +import { DatabaseService } from "#~/Database.ts"; import { ssrDiscordSdk as rest } from "#~/discord/api"; import { fetchChannel, @@ -116,16 +115,12 @@ export const Command = [ roleId = mod; } - yield* Effect.tryPromise(() => - db - .insertInto("tickets_config") - .values({ - message_id: producedMessage.id, - channel_id: ticketChannel?.id, - role_id: roleId, - }) - .execute(), - ); + const db = yield* DatabaseService; + yield* db.insertInto("tickets_config").values({ + message_id: producedMessage.id, + channel_id: ticketChannel?.id, + role_id: roleId, + }); featureStats.ticketChannelSetup( interaction.guild.id, @@ -133,7 +128,6 @@ export const Command = [ ticketChannel?.id ?? interaction.channelId, ); }).pipe( - Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect( @@ -204,13 +198,12 @@ export const Command = [ const { channel, fields, user } = interaction; const concern = fields.getTextInputValue("concern"); - let config = yield* Effect.tryPromise(() => - db - .selectFrom("tickets_config") - .selectAll() - .where("message_id", "=", interaction.message!.id) - .executeTakeFirst(), - ); + const db = yield* DatabaseService; + const configRows = yield* db + .selectFrom("tickets_config") + .selectAll() + .where("message_id", "=", interaction.message.id); + let config = configRows[0]; // If there's no config, that means that the button was set up before the db was set up. Add one with default values if (!config) { @@ -218,13 +211,11 @@ export const Command = [ interaction.guild.id, [SETTINGS.moderator, SETTINGS.modLog], ); - config = yield* Effect.tryPromise(() => - db - .insertInto("tickets_config") - .returningAll() - .values({ message_id: interaction.message!.id, role_id: mod }) - .executeTakeFirst(), - ); + const insertedRows = yield* db + .insertInto("tickets_config") + .returningAll() + .values({ message_id: interaction.message.id, role_id: mod }); + config = insertedRows[0]; if (!config) { yield* Effect.fail( new Error("Something went wrong while fixing tickets config"), @@ -296,7 +287,6 @@ export const Command = [ flags: [MessageFlags.Ephemeral], }); }).pipe( - Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect( @@ -376,7 +366,6 @@ export const Command = [ !!feedback?.trim(), ); }).pipe( - Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.gen(function* () { yield* logEffect("error", "TicketsClose", "Error closing ticket", { diff --git a/app/commands/track.ts b/app/commands/track.ts index d63f3305..1ac64a30 100644 --- a/app/commands/track.ts +++ b/app/commands/track.ts @@ -11,7 +11,6 @@ import { import { Effect } from "effect"; import { logUserMessage } from "#~/commands/report/userLog.ts"; -import { DatabaseLayer } from "#~/Database.ts"; import { client } from "#~/discord/client.server"; import { deleteMessage, @@ -77,7 +76,6 @@ export const Command = [ : [], }); }).pipe( - Effect.provide(DatabaseLayer), Effect.catchAll((error) => Effect.all([ logEffect("error", "Track", "Error tracking message", { @@ -96,9 +94,7 @@ export const Command = [ Effect.gen(function* () { const [, reportId] = interaction.customId.split("|"); - const report = yield* getReportById(reportId).pipe( - Effect.provide(DatabaseLayer), - ); + const report = yield* getReportById(reportId); if (!report) { yield* interactionUpdate(interaction, { @@ -121,7 +117,7 @@ export const Command = [ yield* markMessageAsDeleted( report.reported_message_id, report.guild_id, - ).pipe(Effect.provide(DatabaseLayer)); + ); const logChannel = yield* fetchChannelFromClient( client, diff --git a/app/db.d.ts b/app/db.d.ts index a70fae25..efe998a1 100644 --- a/app/db.d.ts +++ b/app/db.d.ts @@ -1,3 +1,8 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + import type { ColumnType } from "kysely"; export type Generated = diff --git a/app/db.server.ts b/app/db.server.ts deleted file mode 100644 index 9047520f..00000000 --- a/app/db.server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import SQLite from "better-sqlite3"; -import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely"; - -import type { DB } from "./db"; -import { databaseUrl } from "./helpers/env.server"; - -export { SqliteError } from "better-sqlite3"; - -console.log(`Connecting to database at ${databaseUrl}`); - -const sqliteDb = new SQLite(databaseUrl); -// Enable WAL mode to match @effect/sql-sqlite-node's default. -// Both connections MUST use the same journal mode to prevent corruption. -sqliteDb.pragma("journal_mode = WAL"); -// Wait up to 5s for locks instead of failing immediately -sqliteDb.pragma("busy_timeout = 5000"); - -/** Checkpoint WAL to main database and close connection. Call on process shutdown. */ -export function shutdownDatabase() { - sqliteDb.pragma("wal_checkpoint(TRUNCATE)"); - sqliteDb.close(); -} - -export const dialect = new SqliteDialect({ - database: sqliteDb, -}); - -const db = new Kysely({ - dialect, - plugins: [new ParseJSONResultsPlugin()], -}); - -export default db; -export type { DB }; diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 263c5334..864d8f09 100644 --- a/app/discord/activityTracker.ts +++ b/app/discord/activityTracker.ts @@ -1,7 +1,7 @@ import { ChannelType, Events, type Client } from "discord.js"; import { Effect } from "effect"; -import db from "#~/db.server"; +import { db, run } from "#~/Database"; import { getMessageStats } from "#~/helpers/discord.js"; import { threadStats } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; @@ -42,9 +42,8 @@ export async function startActivityTracking(client: Client) { async () => getOrFetchChannel(msg), ); - await db - .insertInto("message_stats") - .values({ + await run( + db.insertInto("message_stats").values({ ...info, code_stats: JSON.stringify(info.code_stats), link_stats: JSON.stringify(info.link_stats), @@ -54,8 +53,8 @@ export async function startActivityTracking(client: Client) { channel_id: msg.channelId, recipient_id: msg.mentions.repliedUser?.id ?? null, channel_category: channelInfo.category, - }) - .execute(); + }), + ); log("debug", "ActivityTracker", "Message stats stored", { messageId: msg.id, @@ -78,13 +77,13 @@ export async function startActivityTracking(client: Client) { async () => { const info = await Effect.runPromise(getMessageStats(msg)); - await updateStatsById(msg.id) - .set({ + await run( + updateStatsById(msg.id).set({ ...info, code_stats: JSON.stringify(info.code_stats), link_stats: JSON.stringify(info.link_stats), - }) - .execute(); + }), + ); log("debug", "ActivityTracker", "Message stats updated", { messageId: msg.id, @@ -103,10 +102,9 @@ export async function startActivityTracking(client: Client) { await trackPerformance( "processMessageDelete", async () => { - await db - .deleteFrom("message_stats") - .where("message_id", "=", msg.id) - .execute(); + await run( + db.deleteFrom("message_stats").where("message_id", "=", msg.id), + ); log("debug", "ActivityTracker", "Message stats deleted", { messageId: msg.id, @@ -120,9 +118,11 @@ export async function startActivityTracking(client: Client) { await trackPerformance( "processReactionAdd", async () => { - await updateStatsById(msg.message.id) - .set({ react_count: (eb) => eb(eb.ref("react_count"), "+", 1) }) - .execute(); + await run( + updateStatsById(msg.message.id).set({ + react_count: (eb) => eb(eb.ref("react_count"), "+", 1), + }), + ); log("debug", "ActivityTracker", "Reaction added to message", { messageId: msg.message.id, @@ -138,9 +138,11 @@ export async function startActivityTracking(client: Client) { await trackPerformance( "processReactionRemove", async () => { - await updateStatsById(msg.message.id) - .set({ react_count: (eb) => eb(eb.ref("react_count"), "-", 1) }) - .execute(); + await run( + updateStatsById(msg.message.id).set({ + react_count: (eb) => eb(eb.ref("react_count"), "-", 1), + }), + ); log("debug", "ActivityTracker", "Reaction removed from message", { messageId: msg.message.id, @@ -164,20 +166,21 @@ export async function reportByGuild(guildId: string) { guildId, }); - const result = await db - .selectFrom("message_stats") - .select((eb) => [ - eb.fn.countAll().as("message_count"), - eb.fn.sum("char_count").as("char_total"), - eb.fn.sum("word_count").as("word_total"), - eb.fn.sum("react_count").as("react_total"), - eb.fn.avg("char_count").as("avg_chars"), - eb.fn.avg("word_count").as("avg_words"), - eb.fn.avg("react_count").as("avg_reacts"), - ]) - .where("guild_id", "=", guildId) - .groupBy("author_id") - .execute(); + const result = await run( + db + .selectFrom("message_stats") + .select((eb) => [ + eb.fn.countAll().as("message_count"), + eb.fn.sum("char_count").as("char_total"), + eb.fn.sum("word_count").as("word_total"), + eb.fn.sum("react_count").as("react_total"), + eb.fn.avg("char_count").as("avg_chars"), + eb.fn.avg("word_count").as("avg_words"), + eb.fn.avg("react_count").as("avg_reacts"), + ]) + .where("guild_id", "=", guildId) + .groupBy("author_id"), + ); log("info", "ActivityTracker", "Guild report generated", { guildId, diff --git a/app/discord/automod.ts b/app/discord/automod.ts index 78989e3f..7aa75345 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -1,8 +1,6 @@ import { Events, type Client } from "discord.js"; -import { Effect } from "effect"; import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; -import { DatabaseLayer } from "#~/Database.js"; import { runEffect } from "#~/effects/runtime.js"; import { isStaff } from "#~/helpers/discord"; import { isSpam } from "#~/helpers/isSpam"; @@ -40,12 +38,7 @@ export default async (bot: Client) => { await message .delete() .then(() => - runEffect( - Effect.provide( - markMessageAsDeleted(message.id, message.guild!.id), - DatabaseLayer, - ), - ), + runEffect(markMessageAsDeleted(message.id, message.guild!.id)), ); featureStats.spamDetected( diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 5a1ec851..7bd4c9fd 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,8 +1,7 @@ import { Events, InteractionType } from "discord.js"; import modActionLogger from "#~/commands/report/modActionLogger"; -import { startIntegrityCheck } from "#~/Database"; -import { shutdownDatabase } from "#~/db.server"; +import { shutdownDatabase, startIntegrityCheck } from "#~/Database"; import { startActivityTracking } from "#~/discord/activityTracker"; import automod from "#~/discord/automod"; import { client, login } from "#~/discord/client.server"; diff --git a/app/discord/honeypotTracker.ts b/app/discord/honeypotTracker.ts index bf7a7498..3f0e8f6b 100644 --- a/app/discord/honeypotTracker.ts +++ b/app/discord/honeypotTracker.ts @@ -1,7 +1,7 @@ import { ChannelType, Events, type Client } from "discord.js"; import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; -import db from "#~/db.server.js"; +import { db, run } from "#~/Database"; import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js"; @@ -41,11 +41,12 @@ export async function startHoneypotTracking(client: Client) { const { guild } = msg; const cacheEntry = configCache[msg.guildId]; if (!cacheEntry || cacheEntry.cachedAt + CACHE_TTL_IN_MS < Date.now()) { - config = await db - .selectFrom("honeypot_config") - .selectAll() - .where("guild_id", "=", msg.guildId) - .execute(); + config = await run( + db + .selectFrom("honeypot_config") + .selectAll() + .where("guild_id", "=", msg.guildId), + ); configCache[msg.guildId] = { config, cachedAt: Date.now() }; log( diff --git a/app/discord/reactjiChanneler.ts b/app/discord/reactjiChanneler.ts index 83acdd1c..2d7e4835 100644 --- a/app/discord/reactjiChanneler.ts +++ b/app/discord/reactjiChanneler.ts @@ -1,6 +1,6 @@ import { Events, type Client } from "discord.js"; -import db from "#~/db.server"; +import { db, runTakeFirst } from "#~/Database"; import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; @@ -40,12 +40,13 @@ export async function startReactjiChanneler(client: Client) { } // Look up config for this guild + emoji combination - const config = await db - .selectFrom("reactji_channeler_config") - .selectAll() - .where("guild_id", "=", guildId) - .where("emoji", "=", emoji) - .executeTakeFirst(); + const config = await runTakeFirst( + db + .selectFrom("reactji_channeler_config") + .selectAll() + .where("guild_id", "=", guildId) + .where("emoji", "=", emoji), + ); if (!config) { return; diff --git a/app/discord/utils.ts b/app/discord/utils.ts index 96da3a3a..53a19aff 100644 --- a/app/discord/utils.ts +++ b/app/discord/utils.ts @@ -1,15 +1,13 @@ import type { Message, TextChannel } from "discord.js"; -import db from "#~/db.server"; +import { db, run, runTakeFirst } from "#~/Database"; import { log } from "#~/helpers/observability"; export async function getOrFetchChannel(msg: Message) { // TODO: cache eviction? - const channelInfo = await db - .selectFrom("channel_info") - .selectAll() - .where("id", "=", msg.channelId) - .executeTakeFirst(); + const channelInfo = await runTakeFirst( + db.selectFrom("channel_info").selectAll().where("id", "=", msg.channelId), + ); if (channelInfo) { log("debug", "ActivityTracker", "Channel info found in cache", { @@ -31,14 +29,13 @@ export async function getOrFetchChannel(msg: Message) { name: data.name, }; - await db - .insertInto("channel_info") - .values({ + await run( + db.insertInto("channel_info").values({ id: msg.channelId, name: data.name, category: data.parent?.name ?? null, - }) - .execute(); + }), + ); log("debug", "ActivityTracker", "Channel info added to cache", { channelId: msg.channelId, diff --git a/app/effects/runtime.ts b/app/effects/runtime.ts index 481677fa..4f8df09c 100644 --- a/app/effects/runtime.ts +++ b/app/effects/runtime.ts @@ -1,5 +1,6 @@ import { Effect, Layer, Logger, LogLevel } from "effect"; +import { runtime, type RuntimeContext } from "#~/Database.js"; import { isProd } from "#~/helpers/env.server.js"; import { log } from "#~/helpers/observability.js"; @@ -14,6 +15,9 @@ import { TracingLive } from "./tracing.js"; * - LoggerLive: Structured JSON logging to stdout * * All effects run through these helpers get both tracing and logging automatically. + * + * Database access is provided by the ManagedRuntime from Database.ts, which holds + * a single SQLite connection open for the process lifetime. */ /** @@ -25,15 +29,16 @@ const RuntimeLive = Layer.merge(TracingLive, Logger.json); /** * Run an Effect and return a Promise that resolves with the success value. - * Automatically provides tracing (Sentry) and logging (JSON to stdout). + * Automatically provides tracing (Sentry), logging (JSON to stdout), and + * database access (via the ManagedRuntime). * Throws if the Effect fails. */ export const runEffect = async ( - effect: Effect.Effect, + effect: Effect.Effect, ): Promise => { try { const program = effect.pipe(Effect.provide(RuntimeLive)); - return Effect.runPromise( + return runtime.runPromise( isProd() ? program.pipe(Logger.withMinimumLogLevel(LogLevel.Info)) : program, @@ -48,11 +53,13 @@ export const runEffect = async ( /** * Run an Effect and return a Promise that resolves with an Exit value. - * Automatically provides tracing (Sentry) and logging (JSON to stdout). + * Automatically provides tracing (Sentry), logging (JSON to stdout), and + * database access (via the ManagedRuntime). * Never throws - use this when you need to inspect failures. */ -export const runEffectExit = (effect: Effect.Effect) => - Effect.runPromiseExit(effect.pipe(Effect.provide(RuntimeLive))); +export const runEffectExit = ( + effect: Effect.Effect, +) => runtime.runPromiseExit(effect.pipe(Effect.provide(RuntimeLive))); /** * Run an Effect synchronously. diff --git a/app/helpers/cohortAnalysis.ts b/app/helpers/cohortAnalysis.ts index 1fe87c84..09f320bd 100644 --- a/app/helpers/cohortAnalysis.ts +++ b/app/helpers/cohortAnalysis.ts @@ -1,6 +1,7 @@ import { sql } from "kysely"; import { partition } from "lodash-es"; +import { run } from "#~/Database"; import type { CodeStats } from "#~/helpers/discord"; import { descriptiveStats, percentile } from "#~/helpers/statistics"; import { createMessageStatsQuery } from "#~/models/activity.server"; @@ -281,7 +282,7 @@ export async function getCohortMetrics( eb(eb.fn.count("author_id"), ">=", minMessageThreshold), ); - const userStats = await userStatsQuery.execute(); + const userStats = await run(userStatsQuery); // Get daily activity for streak calculation const dailyActivityQuery = createMessageStatsQuery(guildId, start, end) @@ -299,7 +300,7 @@ export async function getCohortMetrics( userStats.map((u) => u.author_id), ); - const dailyActivity = await dailyActivityQuery.execute(); + const dailyActivity = await run(dailyActivityQuery); // Group daily activity by user const dailyActivityByUser = dailyActivity.reduce( diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index a6abb54c..baf219bd 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -22,6 +22,7 @@ import { Effect } from "effect"; import { partition } from "lodash-es"; import prettyBytes from "pretty-bytes"; +import type { RuntimeContext } from "#~/Database"; import { resolveMessagePartial } from "#~/effects/discordSdk"; import { NotFoundError, type DiscordApiError } from "#~/effects/errors.ts"; import { @@ -158,10 +159,12 @@ export const isUserContextCommand = ( export const isSlashCommand = (config: AnyCommand): config is SlashCommand => config.command instanceof SlashCommandBuilder; // Effect-based command types -// Handlers must be fully self-contained: E = never, R = never, A = void -// +// Handlers return Effects that may require database access (RuntimeContext). +// The runtime provides these dependencies when the handler is executed. -export type Handler = (interaction: I) => Effect.Effect; +export type Handler = ( + interaction: I, +) => Effect.Effect; export interface SlashCommand { command: SlashCommandBuilder; diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts index 57d10526..d20c91f9 100644 --- a/app/models/activity.server.ts +++ b/app/models/activity.server.ts @@ -1,6 +1,6 @@ import { sql } from "kysely"; -import db, { type DB } from "#~/db.server"; +import { db, run, type DB } from "#~/Database"; import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis"; import { fillDateGaps } from "#~/helpers/dateUtils"; import { getOrFetchUser } from "#~/helpers/userInfoCache.js"; @@ -108,9 +108,9 @@ export async function getUserMessageAnalytics( const [dailyResults, categoryBreakdown, channelBreakdown, userInfo] = await Promise.all([ - dailyQuery.execute(), - categoryQuery.execute(), - channelQuery.execute(), + run(dailyQuery), + run(categoryQuery), + run(channelQuery), getOrFetchUser(userId), ]); @@ -204,7 +204,7 @@ export async function getTopParticipants( ) .limit(config.count); console.log(topMembersQuery.compile().sql); - const topMembers = await topMembersQuery.execute(); + const topMembers = await run(topMembersQuery); const dailyParticipationQuery = db .with("interval_message_stats", () => filteredQuery) @@ -227,7 +227,7 @@ export async function getTopParticipants( topMembers.map((m) => m.author_id), ); console.log(dailyParticipationQuery.compile().sql); - const rawDailyParticipation = await dailyParticipationQuery.execute(); + const rawDailyParticipation = await run(dailyParticipationQuery); // Group by author and fill date gaps inline const groupedData = rawDailyParticipation.reduce((acc, record) => { const { author_id, date } = record; diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts index b43b4b47..fa91682d 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -1,7 +1,13 @@ import { Effect } from "effect"; +import { + db, + run, + runTakeFirst, + runTakeFirstOrThrow, + type DB, +} from "#~/Database"; import { DatabaseService } from "#~/Database.ts"; -import db, { SqliteError, type DB } from "#~/db.server"; import { NotFoundError } from "#~/effects/errors.ts"; import { log, trackPerformance } from "#~/helpers/observability"; @@ -31,11 +37,9 @@ export const fetchGuild = async (guildId: string) => { async () => { log("debug", "Guild", "Fetching guild", { guildId }); - const guild = await db - .selectFrom("guilds") - .selectAll() - .where("id", "=", guildId) - .executeTakeFirst(); + const guild = await runTakeFirst( + db.selectFrom("guilds").selectAll().where("id", "=", guildId), + ); log("debug", "Guild", guild ? "Guild found" : "Guild not found", { guildId, @@ -55,32 +59,17 @@ export const registerGuild = async (guildId: string) => { async () => { log("info", "Guild", "Registering guild", { guildId }); - try { - await db + await run( + db .insertInto("guilds") .values({ id: guildId, settings: JSON.stringify({}), }) - .execute(); + .onConflict((oc) => oc.column("id").doNothing()), + ); - log("info", "Guild", "Guild registered successfully", { guildId }); - } catch (e) { - if ( - e instanceof SqliteError && - e.code === "SQLITE_CONSTRAINT_PRIMARYKEY" - ) { - log("debug", "Guild", "Guild already exists", { guildId }); - // do nothing - } else { - log("error", "Guild", "Failed to register guild", { - guildId, - error: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }); - throw e; - } - } + log("info", "Guild", "Guild registered successfully", { guildId }); }, { guildId }, ); @@ -90,13 +79,14 @@ export const setSettings = async ( guildId: string, settings: SettingsRecord, ) => { - await db - .updateTable("guilds") - .set("settings", (eb) => - eb.fn("json_patch", ["settings", eb.val(JSON.stringify(settings))]), - ) - .where("id", "=", guildId) - .execute(); + await run( + db + .updateTable("guilds") + .set("settings", (eb) => + eb.fn("json_patch", ["settings", eb.val(JSON.stringify(settings))]), + ) + .where("id", "=", guildId), + ); }; export const fetchSettings = async ( @@ -104,17 +94,18 @@ export const fetchSettings = async ( keys: T[], ) => { const result = Object.entries( - 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((eb) => - keys.map((k) => eb.ref("settings", "->>").key(k).as(k)), - ) - .where("id", "=", guildId) + await runTakeFirstOrThrow( + 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((eb) => + keys.map((k) => eb.ref("settings", "->>").key(k).as(k)), + ) + .where("id", "=", guildId), // This cast is also evidence of the pattern being broken - .executeTakeFirstOrThrow(), + ), ) as [T, string][]; return Object.fromEntries(result) as Pick; }; diff --git a/app/models/reportedMessages.ts b/app/models/reportedMessages.ts index 7b428667..78d08e38 100644 --- a/app/models/reportedMessages.ts +++ b/app/models/reportedMessages.ts @@ -2,7 +2,7 @@ import type { Message, User } from "discord.js"; import { Effect } from "effect"; import type { Selectable } from "kysely"; -import { DatabaseLayer, DatabaseService } from "#~/Database"; +import { DatabaseService } from "#~/Database"; import type { DB } from "#~/db"; import { client } from "#~/discord/client.server"; import { logEffect } from "#~/effects/observability"; @@ -324,10 +324,7 @@ const deleteSingleMessage = ( */ export const deleteAllReportedForUser = (userId: string, guildId: string) => Effect.gen(function* () { - const uniqueMessages = yield* Effect.provide( - getUniqueNonDeletedMessages(userId, guildId), - DatabaseLayer, - ); + const uniqueMessages = yield* getUniqueNonDeletedMessages(userId, guildId); if (uniqueMessages.length === 0) { yield* logEffect("info", "ReportedMessage", "No messages to delete", { diff --git a/app/models/session.server.ts b/app/models/session.server.ts index a6ec04b0..0958336d 100644 --- a/app/models/session.server.ts +++ b/app/models/session.server.ts @@ -7,7 +7,13 @@ import { } from "react-router"; import { AuthorizationCode } from "simple-oauth2"; -import db, { type DB } from "#~/db.server"; +import { + db, + run, + runTakeFirst, + runTakeFirstOrThrow, + type DB, +} from "#~/Database"; import { applicationId, discordSecret, @@ -70,15 +76,16 @@ const { sameSite: "lax", }, async createData(data, expires) { - const result = await db - .insertInto("sessions") - .values({ - id: randomUUID(), - data: JSON.stringify(data), - expires: expires?.toString(), - }) - .returning("id") - .executeTakeFirstOrThrow(); + const result = await runTakeFirstOrThrow( + db + .insertInto("sessions") + .values({ + id: randomUUID(), + data: JSON.stringify(data), + expires: expires?.toString(), + }) + .returning("id"), + ); if (!result.id) { console.error({ result, data, expires }); throw new Error("Failed to create session data"); @@ -86,24 +93,29 @@ const { return result.id; }, async readData(id) { - const result = await db - .selectFrom("sessions") - .where("id", "=", id) - .selectAll() - .executeTakeFirst(); + const result = await runTakeFirst( + db.selectFrom("sessions").where("id", "=", id).selectAll(), + ); - return (result?.data as unknown) ?? null; + if (!result?.data) return null; + // @effect/sql-kysely doesn't include ParseJSONResultsPlugin, so JSON + // columns come back as raw strings. Parse before returning to the + // session storage, which expects a deserialized object. + return typeof result.data === "string" + ? JSON.parse(result.data) + : result.data; }, async updateData(id, data, expires) { - await db - .updateTable("sessions") - .set("data", JSON.stringify(data)) - .set("expires", expires?.toString() ?? null) - .where("id", "=", id) - .execute(); + await run( + db + .updateTable("sessions") + .set("data", JSON.stringify(data)) + .set("expires", expires?.toString() ?? null) + .where("id", "=", id), + ); }, async deleteData(id) { - await db.deleteFrom("sessions").where("id", "=", id).execute(); + await run(db.deleteFrom("sessions").where("id", "=", id)); }, }); export type DbSession = Awaited>; diff --git a/app/models/subscriptions.server.ts b/app/models/subscriptions.server.ts index 7475132d..4ecb6dd2 100644 --- a/app/models/subscriptions.server.ts +++ b/app/models/subscriptions.server.ts @@ -1,4 +1,4 @@ -import db from "#~/db.server"; +import { db, run, runTakeFirst } from "#~/Database"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; @@ -17,11 +17,12 @@ export const SubscriptionService = { guildId, }); - const result = await db - .selectFrom("guild_subscriptions") - .selectAll() - .where("guild_id", "=", guildId) - .executeTakeFirst(); + const result = await runTakeFirst( + db + .selectFrom("guild_subscriptions") + .selectAll() + .where("guild_id", "=", guildId), + ); if (result) { log("debug", "Subscription", "Found existing subscription", { @@ -67,29 +68,30 @@ export const SubscriptionService = { const existing = await this.getGuildSubscription(data.guild_id); const isUpdate = !!existing; - await db - .insertInto("guild_subscriptions") - .values({ - guild_id: data.guild_id, - stripe_customer_id: data.stripe_customer_id ?? null, - stripe_subscription_id: data.stripe_subscription_id ?? null, - product_tier: data.product_tier, - status: data.status ?? "active", - current_period_end: data.current_period_end ?? null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - .onConflict((oc) => - oc.column("guild_id").doUpdateSet({ + await run( + db + .insertInto("guild_subscriptions") + .values({ + guild_id: data.guild_id, stripe_customer_id: data.stripe_customer_id ?? null, stripe_subscription_id: data.stripe_subscription_id ?? null, product_tier: data.product_tier, status: data.status ?? "active", current_period_end: data.current_period_end ?? null, + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - }), - ) - .execute(); + }) + .onConflict((oc) => + oc.column("guild_id").doUpdateSet({ + stripe_customer_id: data.stripe_customer_id ?? null, + stripe_subscription_id: data.stripe_subscription_id ?? null, + product_tier: data.product_tier, + status: data.status ?? "active", + current_period_end: data.current_period_end ?? null, + updated_at: new Date().toISOString(), + }), + ), + ); log( "info", @@ -138,15 +140,16 @@ export const SubscriptionService = { throw new Error(`No subscription found for guild ${guildId}`); } - await db - .updateTable("guild_subscriptions") - .set({ - status, - current_period_end: currentPeriodEnd ?? null, - updated_at: new Date().toISOString(), - }) - .where("guild_id", "=", guildId) - .execute(); + await run( + db + .updateTable("guild_subscriptions") + .set({ + status, + current_period_end: currentPeriodEnd ?? null, + updated_at: new Date().toISOString(), + }) + .where("guild_id", "=", guildId), + ); log( "info", @@ -337,30 +340,35 @@ export const SubscriptionService = { log("debug", "Subscription", "Fetching subscription metrics"); const [total, active, free, paid, inactive] = await Promise.all([ - db - .selectFrom("guild_subscriptions") - .select((eb) => eb.fn.countAll().as("count")) - .executeTakeFirst(), - db - .selectFrom("guild_subscriptions") - .select((eb) => eb.fn.countAll().as("count")) - .where("status", "=", "active") - .executeTakeFirst(), - db - .selectFrom("guild_subscriptions") - .select((eb) => eb.fn.countAll().as("count")) - .where("product_tier", "=", "free") - .executeTakeFirst(), - db - .selectFrom("guild_subscriptions") - .select((eb) => eb.fn.countAll().as("count")) - .where("product_tier", "=", "paid") - .executeTakeFirst(), - db - .selectFrom("guild_subscriptions") - .select((eb) => eb.fn.countAll().as("count")) - .where("status", "=", "inactive") - .executeTakeFirst(), + runTakeFirst( + db + .selectFrom("guild_subscriptions") + .select((eb) => eb.fn.countAll().as("count")), + ), + runTakeFirst( + db + .selectFrom("guild_subscriptions") + .select((eb) => eb.fn.countAll().as("count")) + .where("status", "=", "active"), + ), + runTakeFirst( + db + .selectFrom("guild_subscriptions") + .select((eb) => eb.fn.countAll().as("count")) + .where("product_tier", "=", "free"), + ), + runTakeFirst( + db + .selectFrom("guild_subscriptions") + .select((eb) => eb.fn.countAll().as("count")) + .where("product_tier", "=", "paid"), + ), + runTakeFirst( + db + .selectFrom("guild_subscriptions") + .select((eb) => eb.fn.countAll().as("count")) + .where("status", "=", "inactive"), + ), ]); const metrics = { diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 3c81803e..b83d53be 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,6 +1,12 @@ import { randomUUID } from "crypto"; -import db, { type DB } from "#~/db.server"; +import { + db, + run, + runTakeFirst, + runTakeFirstOrThrow, + type DB, +} from "#~/Database"; import { log, trackPerformance } from "#~/helpers/observability"; export type User = DB["users"]; @@ -11,11 +17,9 @@ export async function getUserById(id: User["id"]) { async () => { log("debug", "User", "Fetching user by ID", { userId: id }); - const user = await db - .selectFrom("users") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); + const user = await runTakeFirst( + db.selectFrom("users").selectAll().where("id", "=", id), + ); log("debug", "User", user ? "User found" : "User not found", { userId: id, @@ -36,11 +40,9 @@ export async function getUserByExternalId(externalId: User["externalId"]) { async () => { log("debug", "User", "Fetching user by external ID", { externalId }); - const user = await db - .selectFrom("users") - .selectAll() - .where("externalId", "=", externalId) - .executeTakeFirst(); + const user = await runTakeFirst( + db.selectFrom("users").selectAll().where("externalId", "=", externalId), + ); log( "debug", @@ -67,11 +69,9 @@ export async function getUserByEmail(email: User["email"]) { async () => { log("debug", "User", "Fetching user by email", { email }); - const user = await db - .selectFrom("users") - .selectAll() - .where("email", "=", email) - .executeTakeFirst(); + const user = await runTakeFirst( + db.selectFrom("users").selectAll().where("email", "=", email), + ); log( "debug", @@ -104,18 +104,19 @@ export async function createUser( authProvider: "discord", }); - const out = await db - .insertInto("users") - .values([ - { - id: randomUUID(), - email, - externalId, - authProvider: "discord", - }, - ]) - .returningAll() - .executeTakeFirstOrThrow(); + const out = await runTakeFirstOrThrow( + db + .insertInto("users") + .values([ + { + id: randomUUID(), + email, + externalId, + authProvider: "discord", + }, + ]) + .returningAll(), + ); log("info", "User", "User created successfully", { userId: out.id, @@ -131,5 +132,5 @@ export async function createUser( } export async function deleteUserByEmail(email: User["email"]) { - return db.deleteFrom("users").where("email", "=", email).execute(); + return run(db.deleteFrom("users").where("email", "=", email)); } diff --git a/app/routes/export-data.tsx b/app/routes/export-data.tsx index bd0be81f..d7aa2531 100644 --- a/app/routes/export-data.tsx +++ b/app/routes/export-data.tsx @@ -1,4 +1,4 @@ -import db from "#~/db.server"; +import { db, run, runTakeFirst } from "#~/Database"; import { log, trackPerformance } from "#~/helpers/observability"; import { requireUser } from "#~/models/session.server"; import { SubscriptionService } from "#~/models/subscriptions.server"; @@ -62,16 +62,14 @@ export async function loader({ request }: Route.LoaderArgs) { }); // Get guild settings - const guild = await db - .selectFrom("guilds") - .selectAll() - .where("id", "=", guildId) - .executeTakeFirst(); + const guild = await runTakeFirst( + db.selectFrom("guilds").selectAll().where("id", "=", guildId), + ); if (guild) { exportData.guild = { id: guild.id, - settings: guild.settings, + settings: guild.settings ? JSON.parse(guild.settings) : null, }; } @@ -89,12 +87,13 @@ export async function loader({ request }: Route.LoaderArgs) { } // Get message statistics (aggregated, no actual message content) - const messageStats = await db - .selectFrom("message_stats") - .selectAll() - .where("guild_id", "=", guildId) - .limit(1000) // Limit to prevent huge exports - .execute(); + const messageStats = await run( + db + .selectFrom("message_stats") + .selectAll() + .where("guild_id", "=", guildId) + .limit(1000), // Limit to prevent huge exports + ); if (messageStats.length > 0) { exportData.message_statistics = messageStats.map((stat) => ({ @@ -109,13 +108,14 @@ export async function loader({ request }: Route.LoaderArgs) { } // Get reported messages (sanitized) - const reportedMessages = await db - .selectFrom("reported_messages") - .selectAll() - .where("guild_id", "=", guildId) - .where("deleted_at", "is", null) - .limit(100) - .execute(); + const reportedMessages = await run( + db + .selectFrom("reported_messages") + .selectAll() + .where("guild_id", "=", guildId) + .where("deleted_at", "is", null) + .limit(100), + ); if (reportedMessages.length > 0) { exportData.reported_messages = reportedMessages.map((report) => ({ @@ -188,26 +188,23 @@ export async function action({ request }: Route.ActionArgs) { } // Soft delete reported messages for this guild - await db - .updateTable("reported_messages") - .set({ deleted_at: new Date().toISOString() }) - .where("guild_id", "=", guildId) - .execute(); + await run( + db + .updateTable("reported_messages") + .set({ deleted_at: new Date().toISOString() }) + .where("guild_id", "=", guildId), + ); // Delete message stats - await db - .deleteFrom("message_stats") - .where("guild_id", "=", guildId) - .execute(); + await run(db.deleteFrom("message_stats").where("guild_id", "=", guildId)); // Delete subscription data - await db - .deleteFrom("guild_subscriptions") - .where("guild_id", "=", guildId) - .execute(); + await run( + db.deleteFrom("guild_subscriptions").where("guild_id", "=", guildId), + ); // Delete guild settings - await db.deleteFrom("guilds").where("id", "=", guildId).execute(); + await run(db.deleteFrom("guilds").where("id", "=", guildId)); log("info", "DataDelete", "Guild data deleted successfully", { userId: user.id, diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index c88cd624..5d95aee8 100644 --- a/app/routes/healthcheck.tsx +++ b/app/routes/healthcheck.tsx @@ -1,5 +1,5 @@ // learn more: https://fly.io/docs/reference/configuration/#services-http_checks -import db from "#~/db.server"; +import { db, run } from "#~/Database"; import type { Route } from "./+types/healthcheck"; @@ -12,13 +12,14 @@ export async function loader({ request }: Route.LoaderArgs) { // if we can connect to the database and make a simple query // and make a HEAD request to ourselves, then we're good. await Promise.all([ - db - // @ts-expect-error because kysely doesn't generate types for sqlite_master - .selectFrom("sqlite_master") - // @ts-expect-error because kysely doesn't generate types for sqlite_master - .select("name") - .where("type", "=", "table") - .execute(), + run( + db + // @ts-expect-error because kysely doesn't generate types for sqlite_master + .selectFrom("sqlite_master") + // @ts-expect-error because kysely doesn't generate types for sqlite_master + .select("name") + .where("type", "=", "table"), + ), fetch(url.toString(), { method: "HEAD" }).then((r) => { if (!r.ok) { return Promise.reject( diff --git a/notes/2026-02-01_1_db-migration.md b/notes/2026-02-01_1_db-migration.md new file mode 100644 index 00000000..852e264d --- /dev/null +++ b/notes/2026-02-01_1_db-migration.md @@ -0,0 +1,26 @@ +# Database Migration: db.server.ts -> Database.ts + +## What changed + +All database access now flows through a single `ManagedRuntime` in `Database.ts`. +The old `db.server.ts` file was deleted. + +### Key architectural changes + +1. **Database.ts** now exports everything: `runtime`, `db`, `run`, `runTakeFirst`, + `runTakeFirstOrThrow`, `shutdownDatabase`, `DB` type, `RuntimeContext` type +2. **effects/runtime.ts** `runEffect`/`runEffectExit` now use `runtime.runPromise()` + instead of `Effect.runPromise()`. This means database services are automatically + provided to all effects run through `runEffect`. +3. **Handler type** changed from `Effect` to + `Effect` — handlers no longer need to provide + `DatabaseLayer` themselves. +4. **EscalationServiceLive** no longer has `Layer.provide(DatabaseLayer)` — it gets + DatabaseService from the runtime when provided via `Effect.provide(EscalationServiceLive)`. + +### Important for future code + +- **Don't** use `Effect.provide(DatabaseLayer)` in handlers — the runtime handles it +- **Do** use `yield* DatabaseService` to get the db in Effect code +- **Do** use bridge functions (`db`, `run`, `runTakeFirst`, etc.) for legacy async code +- Both paths use the same single SQLite connection via the ManagedRuntime diff --git a/notes/EFFECT.md b/notes/EFFECT.md index 70763e6e..fcf5cf7f 100644 --- a/notes/EFFECT.md +++ b/notes/EFFECT.md @@ -40,7 +40,7 @@ export const myHandler = (input: Input) => // 3. Return value return result; }).pipe( - Effect.provide(DatabaseLayer), // Inject dependencies + // DatabaseLayer is provided by the ManagedRuntime — no need to provide it here Effect.catchAll((e) => ...), // Handle errors Effect.withSpan("myHandler"), // Add tracing ); @@ -274,7 +274,7 @@ export const fetchMemberOrNull = (guild: Guild, userId: string) => ```typescript import { Effect } from "effect"; -import { DatabaseLayer } from "#~/Database"; +import { DatabaseService } from "#~/Database"; import { logEffect } from "#~/effects/observability"; export const handleMyCommand = (input: Input) => @@ -291,13 +291,13 @@ export const handleMyCommand = (input: Input) => return result; }).pipe( + // DatabaseLayer is provided by the ManagedRuntime — no need to provide it here Effect.catchAll((error) => logEffect("error", "MyCommand", "Command failed", { error: String(error), }), ), Effect.withSpan("handleMyCommand"), - Effect.provide(DatabaseLayer), ); ``` @@ -305,7 +305,7 @@ export const handleMyCommand = (input: Input) => ```typescript import { Context, Effect, Layer } from "effect"; -import { DatabaseLayer, DatabaseService } from "#~/Database"; +import { DatabaseService } from "#~/Database"; // 1. Interface export interface IMyService { @@ -319,6 +319,7 @@ export class MyService extends Context.Tag("MyService")< >() {} // 3. Implementation +// DatabaseService is provided by the ManagedRuntime, no Layer.provide needed export const MyServiceLive = Layer.effect( MyService, Effect.gen(function* () { @@ -332,7 +333,7 @@ export const MyServiceLive = Layer.effect( ), }; }), -).pipe(Layer.provide(DatabaseLayer)); +); ``` ## Anti-Patterns