From 9ec59b5c8a17eaacf989513adf7bf80850a36885 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 13:11:46 -0500 Subject: [PATCH 1/5] Replace db.server.ts with ManagedRuntime-based Database module Consolidate all database access through a single ManagedRuntime in Database.ts. Add bridge functions (run, runTakeFirst, runTakeFirstOrThrow) for legacy async/await code and shutdownDatabase for clean WAL checkpointing on process exit. Co-Authored-By: Claude Opus 4.5 --- app/Database.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++---- app/db.d.ts | 5 ++++ app/db.server.ts | 34 ---------------------- 3 files changed, 72 insertions(+), 40 deletions(-) delete mode 100644 app/db.server.ts 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/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 }; From 89c74e8ecffc89ff7dfe761f5b4b1187130c6164 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 13:12:01 -0500 Subject: [PATCH 2/5] Update Effect runtime to use ManagedRuntime for automatic DB provision runEffect and runEffectExit now use runtime.runPromise() instead of Effect.runPromise(), so all effects automatically receive database access without manual DatabaseLayer provision. Co-Authored-By: Claude Opus 4.5 --- app/effects/runtime.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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. From 87004a104c7cede1c77df50fe1e300f80baea0b3 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 13:12:15 -0500 Subject: [PATCH 3/5] Remove manual DatabaseLayer provision from Effect handlers and services DatabaseLayer is now provided globally via ManagedRuntime, so all Effect.provide(DatabaseLayer) and Layer.provide(DatabaseLayer) calls are no longer needed in commands, handlers, and services. Co-Authored-By: Claude Opus 4.5 --- app/commands/escalate/escalationResolver.ts | 2 -- app/commands/escalate/handlers.ts | 14 ++++---------- app/commands/escalate/service.ts | 4 ++-- app/commands/report.ts | 2 -- app/commands/report/modActionLogger.ts | 18 +++++------------- app/commands/report/userLog.ts | 14 ++------------ app/commands/track.ts | 8 ++------ app/discord/automod.ts | 9 +-------- app/discord/gateway.ts | 3 +-- app/models/reportedMessages.ts | 7 ++----- 10 files changed, 19 insertions(+), 62 deletions(-) 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/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/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/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", { From 778c9f13fb0163ac37c0cd83297148232efbef1b Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 13:12:36 -0500 Subject: [PATCH 4/5] Migrate async/await database code to use bridge functions Replace direct db.server.ts imports with Database module bridge functions (run, runTakeFirst, runTakeFirstOrThrow) across all models, routes, discord handlers, and helpers. Add explicit JSON parsing where ParseJSONResultsPlugin was previously relied upon. Co-Authored-By: Claude Opus 4.5 --- app/commands/setup.ts | 7 +- app/discord/activityTracker.ts | 71 +++++++++-------- app/discord/honeypotTracker.ts | 13 ++-- app/discord/reactjiChanneler.ts | 15 ++-- app/discord/utils.ts | 19 ++--- app/helpers/cohortAnalysis.ts | 5 +- app/helpers/discord.ts | 9 ++- app/models/activity.server.ts | 12 +-- app/models/guilds.server.ts | 77 ++++++++---------- app/models/session.server.ts | 58 ++++++++------ app/models/subscriptions.server.ts | 120 +++++++++++++++-------------- app/models/user.server.ts | 59 +++++++------- app/routes/export-data.tsx | 65 ++++++++-------- app/routes/healthcheck.tsx | 17 ++-- 14 files changed, 282 insertions(+), 265 deletions(-) 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/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/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/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/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( From 1307b6d43a57762f9b98d6cc8ee526119df7008e Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 13:12:53 -0500 Subject: [PATCH 5/5] Migrate setup commands to DatabaseService and update documentation Convert setupHoneypot, setupReactjiChannel, and setupTickets to yield DatabaseService directly instead of wrapping queries in Effect.tryPromise. Update EFFECT.md guide and add migration notes. Co-Authored-By: Claude Opus 4.5 --- app/commands/setupHoneypot.ts | 26 +++++++--------- app/commands/setupReactjiChannel.ts | 36 +++++++++++----------- app/commands/setupTickets.ts | 47 +++++++++++------------------ notes/2026-02-01_1_db-migration.md | 26 ++++++++++++++++ notes/EFFECT.md | 11 ++++--- 5 files changed, 78 insertions(+), 68 deletions(-) create mode 100644 notes/2026-02-01_1_db-migration.md 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/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