From c7f62bd1e754d1829e446008cdbb9e4a3806e095 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 16:35:35 -0500 Subject: [PATCH 1/9] Refactor Database: Layer.scoped for SQLite, Effect-based integrity check Use SqliteClient.make with Layer.scoped so the connection lifetime is tied to the layer scope (Effect.scoped was closing it immediately). Inline PRAGMA busy_timeout into the layer construction. Convert integrity check to Effect.repeat(Schedule) and checkpointWal to return an Effect. Remove runtime, bridge functions, and shutdown helpers that move to AppRuntime in the next commit. Co-Authored-By: Claude Opus 4.5 --- app/Database.ts | 114 +++++++++++------------------------------------- 1 file changed, 26 insertions(+), 88 deletions(-) diff --git a/app/Database.ts b/app/Database.ts index 0f80a44..4cd6282 100644 --- a/app/Database.ts +++ b/app/Database.ts @@ -1,15 +1,16 @@ -import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { Context, Effect, Layer, Schedule } from "effect"; +import * as Reactivity from "@effect/experimental/Reactivity"; import { SqlClient } from "@effect/sql"; import * as Sqlite from "@effect/sql-kysely/Sqlite"; import { SqliteClient } from "@effect/sql-sqlite-node"; import { ResultLengthMismatch, SqlError } from "@effect/sql/SqlError"; import type { DB } from "./db"; -import { DatabaseCorruptionError, NotFoundError } from "./effects/errors"; +import { DatabaseCorruptionError } from "./effects/errors"; +import { logEffect } from "./effects/observability"; import { databaseUrl, emergencyWebhook } from "./helpers/env.server"; import { log } from "./helpers/observability"; -import { scheduleTask } from "./helpers/schedule"; // Re-export SQL errors and DB type for consumers export { SqlError, ResultLengthMismatch }; @@ -26,9 +27,12 @@ export class DatabaseService extends Context.Tag("DatabaseService")< // Base SQLite client layer // Note: WAL mode is enabled by default by @effect/sql-sqlite-node -const SqliteLive = SqliteClient.layer({ - filename: databaseUrl, -}); +const SqliteLive = Layer.scoped( + SqlClient.SqlClient, + SqliteClient.make({ + filename: databaseUrl, + }).pipe(Effect.tap((sql) => sql.unsafe("PRAGMA busy_timeout = 5000"))), +).pipe(Layer.provide(Reactivity.layer)); // Kysely service layer - provides the effectified Kysely instance const KyselyLive = Layer.effect(DatabaseService, Sqlite.make()).pipe( @@ -40,72 +44,13 @@ 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* () { +export function checkpointWal() { + return 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); - } + yield* sql.unsafe("PRAGMA wal_checkpoint(TRUNCATE)"); + }); } -// --- 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; - const sendWebhookAlert = (message: string) => Effect.tryPromise({ try: () => @@ -149,25 +94,18 @@ export const runIntegrityCheck = Effect.gen(function* () { return yield* new DatabaseCorruptionError({ errors }); }).pipe( - Effect.catchTag("SqlError", (e) => - Effect.gen(function* () { - log("error", "IntegrityCheck", "Integrity check failed to run", { - error: e.message, - }); - yield* sendWebhookAlert( - `🚨 **Database Integrity Check Failed**\n\`\`\`\n${e.message}\n\`\`\``, - ); - return yield* e; - }), + Effect.provide(SqliteLive), + Effect.repeat(Schedule.fixed("6 hours")), + Effect.catchTag("SqlError", (error) => + Effect.all([ + logEffect("error", "IntegrityCheck", "Integrity check failed to run", { + error, + }), + sendWebhookAlert( + `🚨 **Database Integrity Check Failed**\n\`\`\`\n${error.message}\n${String(error.cause)}\n${error.stack}\n\`\`\``, + ), + ]), ), + Effect.catchAll(() => Effect.succeed(null)), Effect.withSpan("runIntegrityCheck"), ); - -/** Start the twice-daily integrity check scheduler */ -export function startIntegrityCheck() { - return scheduleTask("IntegrityCheck", TWELVE_HOURS, () => { - runtime.runPromise(runIntegrityCheck).catch(() => { - // Errors already logged and webhook sent - }); - }); -} From 2e4118b3da49aaaed7362319f49b878758b2afa2 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 16:35:53 -0500 Subject: [PATCH 2/9] Extract AppRuntime module and update import paths Move ManagedRuntime, layer composition, and bridge functions (db, run, runTakeFirst, runTakeFirstOrThrow, posthogClient) from Database.ts into a dedicated AppRuntime.ts. The AppLayer now includes DatabaseLayer, PostHogServiceLive, and FeatureFlagServiceLive. Update all consumer imports to use #~/AppRuntime. Co-Authored-By: Claude Opus 4.5 --- app/AppRuntime.ts | 45 ++++++++++++++++++++++++++++++ app/discord/activityTracker.ts | 2 +- app/discord/honeypotTracker.ts | 2 +- app/discord/reactjiChanneler.ts | 2 +- app/discord/utils.ts | 2 +- app/effects/runtime.ts | 2 +- app/helpers/cohortAnalysis.ts | 2 +- app/helpers/discord.ts | 2 +- app/helpers/metrics.ts | 3 +- app/models/activity.server.ts | 5 ++-- app/models/guilds.server.ts | 10 ++----- app/models/session.server.ts | 9 ++---- app/models/subscriptions.server.ts | 2 +- app/models/user.server.ts | 9 ++---- app/routes/export-data.tsx | 2 +- app/routes/healthcheck.tsx | 2 +- 16 files changed, 66 insertions(+), 35 deletions(-) create mode 100644 app/AppRuntime.ts diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts new file mode 100644 index 0000000..3eaa5df --- /dev/null +++ b/app/AppRuntime.ts @@ -0,0 +1,45 @@ +import { Effect, Layer, ManagedRuntime } from "effect"; + +import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database"; +import { NotFoundError } from "#~/effects/errors"; + +// App layer: database + PostHog + feature flags +// FeatureFlagServiceLive depends on both DatabaseService and PostHogService +const AppLayer = Layer.mergeAll(DatabaseLayer); + +// ManagedRuntime keeps the AppLayer 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(AppLayer); + +// 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 PostHog client for use by metrics.ts (null when no API key configured). +export const db: EffectKysely = await runtime.runPromise(DatabaseService); + +// --- 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: "" })), + ), + ); diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 864d8f0..5c82bd8 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, run } from "#~/Database"; +import { db, run } from "#~/AppRuntime"; import { getMessageStats } from "#~/helpers/discord.js"; import { threadStats } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; diff --git a/app/discord/honeypotTracker.ts b/app/discord/honeypotTracker.ts index 3f0e8f6..239fbc0 100644 --- a/app/discord/honeypotTracker.ts +++ b/app/discord/honeypotTracker.ts @@ -1,7 +1,7 @@ import { ChannelType, Events, type Client } from "discord.js"; +import { db, run } from "#~/AppRuntime"; import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; -import { db, run } from "#~/Database"; import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js"; diff --git a/app/discord/reactjiChanneler.ts b/app/discord/reactjiChanneler.ts index 2d7e483..65ffd20 100644 --- a/app/discord/reactjiChanneler.ts +++ b/app/discord/reactjiChanneler.ts @@ -1,6 +1,6 @@ import { Events, type Client } from "discord.js"; -import { db, runTakeFirst } from "#~/Database"; +import { db, runTakeFirst } from "#~/AppRuntime"; import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; diff --git a/app/discord/utils.ts b/app/discord/utils.ts index 53a19af..bc28884 100644 --- a/app/discord/utils.ts +++ b/app/discord/utils.ts @@ -1,6 +1,6 @@ import type { Message, TextChannel } from "discord.js"; -import { db, run, runTakeFirst } from "#~/Database"; +import { db, run, runTakeFirst } from "#~/AppRuntime"; import { log } from "#~/helpers/observability"; export async function getOrFetchChannel(msg: Message) { diff --git a/app/effects/runtime.ts b/app/effects/runtime.ts index 4f8df09..4fb5b8f 100644 --- a/app/effects/runtime.ts +++ b/app/effects/runtime.ts @@ -1,6 +1,6 @@ import { Effect, Layer, Logger, LogLevel } from "effect"; -import { runtime, type RuntimeContext } from "#~/Database.js"; +import { runtime, type RuntimeContext } from "#~/AppRuntime.js"; import { isProd } from "#~/helpers/env.server.js"; import { log } from "#~/helpers/observability.js"; diff --git a/app/helpers/cohortAnalysis.ts b/app/helpers/cohortAnalysis.ts index 09f320b..eca5973 100644 --- a/app/helpers/cohortAnalysis.ts +++ b/app/helpers/cohortAnalysis.ts @@ -1,7 +1,7 @@ import { sql } from "kysely"; import { partition } from "lodash-es"; -import { run } from "#~/Database"; +import { run } from "#~/AppRuntime"; import type { CodeStats } from "#~/helpers/discord"; import { descriptiveStats, percentile } from "#~/helpers/statistics"; import { createMessageStatsQuery } from "#~/models/activity.server"; diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index baf219b..d4f588f 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -22,7 +22,7 @@ import { Effect } from "effect"; import { partition } from "lodash-es"; import prettyBytes from "pretty-bytes"; -import type { RuntimeContext } from "#~/Database"; +import type { RuntimeContext } from "#~/AppRuntime"; import { resolveMessagePartial } from "#~/effects/discordSdk"; import { NotFoundError, type DiscordApiError } from "#~/effects/errors.ts"; import { diff --git a/app/helpers/metrics.ts b/app/helpers/metrics.ts index c26a30d..49e9465 100644 --- a/app/helpers/metrics.ts +++ b/app/helpers/metrics.ts @@ -8,9 +8,10 @@ import type { } from "discord.js"; import { PostHog } from "posthog-node"; -import { posthogApiKey, posthogHost } from "#~/helpers/env.server"; import { log } from "#~/helpers/observability"; +import { posthogApiKey, posthogHost } from "./env.server"; + type EventValue = string | number | boolean; type EmitEventData = Record; diff --git a/app/models/activity.server.ts b/app/models/activity.server.ts index d20c91f..4e39754 100644 --- a/app/models/activity.server.ts +++ b/app/models/activity.server.ts @@ -1,9 +1,10 @@ import { sql } from "kysely"; -import { db, run, type DB } from "#~/Database"; +import { db, run } from "#~/AppRuntime"; +import { type DB } from "#~/Database"; import { getUserCohortAnalysis } from "#~/helpers/cohortAnalysis"; import { fillDateGaps } from "#~/helpers/dateUtils"; -import { getOrFetchUser } from "#~/helpers/userInfoCache.js"; +import { getOrFetchUser } from "#~/helpers/userInfoCache"; type MessageStats = DB["message_stats"]; diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts index fa91682..590d640 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -1,13 +1,7 @@ import { Effect } from "effect"; -import { - db, - run, - runTakeFirst, - runTakeFirstOrThrow, - type DB, -} from "#~/Database"; -import { DatabaseService } from "#~/Database.ts"; +import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime"; +import { DatabaseService, type DB } from "#~/Database"; import { NotFoundError } from "#~/effects/errors.ts"; import { log, trackPerformance } from "#~/helpers/observability"; diff --git a/app/models/session.server.ts b/app/models/session.server.ts index 0958336..18cc0fd 100644 --- a/app/models/session.server.ts +++ b/app/models/session.server.ts @@ -7,13 +7,8 @@ import { } from "react-router"; import { AuthorizationCode } from "simple-oauth2"; -import { - db, - run, - runTakeFirst, - runTakeFirstOrThrow, - type DB, -} from "#~/Database"; +import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime"; +import { type DB } from "#~/Database"; import { applicationId, discordSecret, diff --git a/app/models/subscriptions.server.ts b/app/models/subscriptions.server.ts index 4ecb6dd..cdc3f49 100644 --- a/app/models/subscriptions.server.ts +++ b/app/models/subscriptions.server.ts @@ -1,4 +1,4 @@ -import { db, run, runTakeFirst } from "#~/Database"; +import { db, run, runTakeFirst } from "#~/AppRuntime"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; diff --git a/app/models/user.server.ts b/app/models/user.server.ts index b83d53b..60de562 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -1,12 +1,7 @@ import { randomUUID } from "crypto"; -import { - db, - run, - runTakeFirst, - runTakeFirstOrThrow, - type DB, -} from "#~/Database"; +import { db, run, runTakeFirst, runTakeFirstOrThrow } from "#~/AppRuntime"; +import { type DB } from "#~/Database"; import { log, trackPerformance } from "#~/helpers/observability"; export type User = DB["users"]; diff --git a/app/routes/export-data.tsx b/app/routes/export-data.tsx index d7aa253..3dceab8 100644 --- a/app/routes/export-data.tsx +++ b/app/routes/export-data.tsx @@ -1,4 +1,4 @@ -import { db, run, runTakeFirst } from "#~/Database"; +import { db, run, runTakeFirst } from "#~/AppRuntime"; import { log, trackPerformance } from "#~/helpers/observability"; import { requireUser } from "#~/models/session.server"; import { SubscriptionService } from "#~/models/subscriptions.server"; diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index 5d95aee..4882dac 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, run } from "#~/Database"; +import { db, run } from "#~/AppRuntime"; import type { Route } from "./+types/healthcheck"; From 16b171efbaa929c591cc75e49e153cff6b332b55 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 16:36:09 -0500 Subject: [PATCH 3/9] Convert server startup and Discord gateway to Effect Wrap the startup sequence in an Effect.gen pipeline: init Discord gateway, fork integrity check as a daemon fiber, register commands, and wire up graceful shutdown (WAL checkpoint + runtime dispose). Convert gateway init and registerCommand to return Effects so they compose into the startup pipeline. Move shutdown handler from gateway to server.ts where the runtime is available. Co-Authored-By: Claude Opus 4.5 --- app/discord/deployCommands.server.ts | 22 ++++++----- app/discord/gateway.ts | 27 ++----------- app/server.ts | 59 ++++++++++++++++++++-------- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 9ae7529..b6ad89a 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -6,6 +6,7 @@ import { type OAuth2Guild, type SlashCommandBuilder, } from "discord.js"; +import { Effect } from "effect"; import { ssrDiscordSdk } from "#~/discord/api"; import { @@ -178,15 +179,18 @@ export const deployTestCommands = async ( }; const commands = new Map(); -export const registerCommand = (config: AnyCommand | AnyCommand[]) => { - if (Array.isArray(config)) { - config.forEach((c) => { - commands.set(c.command.name, c); - }); - return; - } - commands.set(config.command.name, config); -}; +export const registerCommand = ( + config: AnyCommand | AnyCommand[], +): Effect.Effect => + Effect.sync(() => { + if (Array.isArray(config)) { + config.forEach((c) => { + commands.set(c.command.name, c); + }); + return; + } + commands.set(config.command.name, config); + }); export const matchCommand = (customId: string) => { const config = commands.get(customId); if (config) { diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 7bd4c9f..54add3d 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,7 +1,7 @@ import { Events, InteractionType } from "discord.js"; +import { Effect } from "effect"; import modActionLogger from "#~/commands/report/modActionLogger"; -import { shutdownDatabase, startIntegrityCheck } from "#~/Database"; import { startActivityTracking } from "#~/discord/activityTracker"; import automod from "#~/discord/automod"; import { client, login } from "#~/discord/client.server"; @@ -11,7 +11,7 @@ import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; import { runEffect } from "#~/effects/runtime"; import { type AnyCommand } from "#~/helpers/discord.ts"; -import { botStats, shutdownMetrics } from "#~/helpers/metrics"; +import { botStats } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; @@ -23,7 +23,7 @@ declare global { var __discordGatewayInitialized: boolean | undefined; } -export default function init() { +export const initDiscordBot: Effect.Effect = Effect.sync(() => { if (globalThis.__discordGatewayInitialized) { log( "info", @@ -73,9 +73,6 @@ export default function init() { // Start escalation resolver scheduler (must be after client is ready) startEscalationResolver(client); - // Start twice-daily database integrity check - startIntegrityCheck(); - log("info", "Gateway", "Gateway initialization completed", { guildCount: client.guilds.cache.size, userCount: client.users.cache.size, @@ -181,20 +178,4 @@ export default function init() { // Track reconnections in business analytics botStats.reconnection(client.guilds.cache.size, client.users.cache.size); }); - - // Graceful shutdown handler to flush metrics and close database - const handleShutdown = async (signal: string) => { - log("info", "Gateway", `Received ${signal}, shutting down gracefully`, {}); - await shutdownMetrics(); - try { - shutdownDatabase(); - log("info", "Gateway", "Database closed cleanly", {}); - } catch (e) { - log("error", "Gateway", "Error closing database", { error: String(e) }); - } - process.exit(0); - }; - - process.on("SIGTERM", () => void handleShutdown("SIGTERM")); - process.on("SIGINT", () => void handleShutdown("SIGINT")); -} +}); diff --git a/app/server.ts b/app/server.ts index 18e3dcf..164cbd4 100644 --- a/app/server.ts +++ b/app/server.ts @@ -2,6 +2,7 @@ import "react-router"; import bodyParser from "body-parser"; import { verifyKey } from "discord-interactions"; +import { Effect } from "effect"; import express from "express"; import pinoHttp from "pino-http"; @@ -16,9 +17,13 @@ import { Command as setupReactjiChannel } from "#~/commands/setupReactjiChannel" import { Command as setupTicket } from "#~/commands/setupTickets"; import { Command as track } from "#~/commands/track"; import { registerCommand } from "#~/discord/deployCommands.server"; -import discordBot from "#~/discord/gateway"; +import { initDiscordBot } from "#~/discord/gateway"; import { applicationKey } from "#~/helpers/env.server"; +import { runtime } from "./AppRuntime"; +import { checkpointWal, runIntegrityCheck } from "./Database"; +import { logEffect } from "./effects/observability"; + export const app = express(); const logger = pinoHttp(); @@ -57,19 +62,41 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { next(); }); -/** - * Initialize Discord gateway. - */ -discordBot(); +const startup = Effect.gen(function* () { + yield* initDiscordBot; + yield* Effect.forkDaemon(runIntegrityCheck); + + yield* registerCommand(setup); + yield* registerCommand(report); + yield* registerCommand(forceBan); + yield* registerCommand(track); + yield* registerCommand(setupTicket); + yield* registerCommand(setupReactjiChannel); + yield* registerCommand(EscalationCommands); + yield* registerCommand(setupHoneypot); + + // Graceful shutdown handler to checkpoint WAL and dispose the runtime + // (tears down PostHog finalizer, feature flag interval, and SQLite connection) + const handleShutdown = (signal: string) => + runtime + .runPromise( + Effect.gen(function* () { + yield* logEffect("info", "Server", `Received ${signal}`); + try { + yield* checkpointWal(); + yield* logEffect("info", "Server", "Database WAL checkpointed"); + } catch (e) { + yield* logEffect("error", "Server", "Error checkpointing WAL", { + error: String(e), + }); + } + process.exit(0); + }), + ) + .then(() => runtime.dispose().then(() => console.log("ok"))); + + process.on("SIGTERM", () => void handleShutdown("SIGTERM")); + process.on("SIGINT", () => void handleShutdown("SIGINT")); +}); -/** - * Register Discord commands. - */ -registerCommand(setup); -registerCommand(report); -registerCommand(forceBan); -registerCommand(track); -registerCommand(setupTicket); -registerCommand(setupReactjiChannel); -registerCommand(EscalationCommands); -registerCommand(setupHoneypot); +void Effect.runPromise(startup); From bad2febab2a2f4f79fa4d4845dfa4b0d91b92953 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 17:12:38 -0500 Subject: [PATCH 4/9] Fix top-level async/await by explicitly targeting esnext on the server --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 9db41c5..86a0f92 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { reactRouter } from "@react-router/dev/vite"; export default defineConfig(({ isSsrBuild }) => ({ build: { + target: isSsrBuild ? "esnext" : undefined, sourcemap: true, rollupOptions: isSsrBuild ? { input: "./app/server.ts" } : undefined, }, From fde31da5d1f978713a6c562abb6015d5b145c519 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 17:28:59 -0500 Subject: [PATCH 5/9] Use runtime instead of providing db --- app/Database.ts | 1 - app/server.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Database.ts b/app/Database.ts index 4cd6282..da7265a 100644 --- a/app/Database.ts +++ b/app/Database.ts @@ -94,7 +94,6 @@ export const runIntegrityCheck = Effect.gen(function* () { return yield* new DatabaseCorruptionError({ errors }); }).pipe( - Effect.provide(SqliteLive), Effect.repeat(Schedule.fixed("6 hours")), Effect.catchTag("SqlError", (error) => Effect.all([ diff --git a/app/server.ts b/app/server.ts index 164cbd4..5201f89 100644 --- a/app/server.ts +++ b/app/server.ts @@ -64,7 +64,7 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { const startup = Effect.gen(function* () { yield* initDiscordBot; - yield* Effect.forkDaemon(runIntegrityCheck); + yield* runtime.runFork(runIntegrityCheck); yield* registerCommand(setup); yield* registerCommand(report); From 57176f86acb4475be022e71a708006cfe1ee099e Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 18:30:28 -0500 Subject: [PATCH 6/9] Logs --- app/discord/deployCommands.server.ts | 10 +++++++++- app/discord/gateway.ts | 7 ++++--- app/server.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index b6ad89a..956a330 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -190,7 +190,15 @@ export const registerCommand = ( return; } commands.set(config.command.name, config); - }); + }).pipe( + Effect.withSpan("registerCommand", { + attributes: { + name: Array.isArray(config) + ? config.map((c) => c.command.name) + : config.command.name, + }, + }), + ); export const matchCommand = (customId: string) => { const config = commands.get(customId); if (config) { diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 54add3d..e7edb48 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -9,6 +9,7 @@ import { deployCommands, matchCommand } from "#~/discord/deployCommands.server"; import { startEscalationResolver } from "#~/discord/escalationResolver"; import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; +import { logEffect } from "#~/effects/observability.ts"; import { runEffect } from "#~/effects/runtime"; import { type AnyCommand } from "#~/helpers/discord.ts"; import { botStats } from "#~/helpers/metrics"; @@ -23,9 +24,9 @@ declare global { var __discordGatewayInitialized: boolean | undefined; } -export const initDiscordBot: Effect.Effect = Effect.sync(() => { +export const initDiscordBot: Effect.Effect = Effect.gen(function* () { if (globalThis.__discordGatewayInitialized) { - log( + yield* logEffect( "info", "Gateway", "Gateway already initialized, skipping duplicate init", @@ -33,7 +34,7 @@ export const initDiscordBot: Effect.Effect = Effect.sync(() => { return; } - log("info", "Gateway", "Initializing Discord gateway"); + yield* logEffect("info", "Gateway", "Initializing Discord gateway"); globalThis.__discordGatewayInitialized = true; void login(); diff --git a/app/server.ts b/app/server.ts index 5201f89..929d37b 100644 --- a/app/server.ts +++ b/app/server.ts @@ -63,9 +63,13 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { }); const startup = Effect.gen(function* () { + yield* logEffect("debug", "Server", "startup init"); yield* initDiscordBot; + yield* logEffect("debug", "Server", "scheduling integrity check"); yield* runtime.runFork(runIntegrityCheck); + yield* logEffect("debug", "Server", "initializing commands"); + yield* registerCommand(setup); yield* registerCommand(report); yield* registerCommand(forceBan); @@ -95,8 +99,11 @@ const startup = Effect.gen(function* () { ) .then(() => runtime.dispose().then(() => console.log("ok"))); + yield* logEffect("debug", "Server", "setting signal handlers"); process.on("SIGTERM", () => void handleShutdown("SIGTERM")); process.on("SIGINT", () => void handleShutdown("SIGINT")); }); -void Effect.runPromise(startup); +console.log("running program"); +const exit = await Effect.runPromiseExit(startup); +console.log({ exit }); From 17b0cb56d967d71b0df78c39866501a6e159b426 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 19:00:24 -0500 Subject: [PATCH 7/9] Move initialization code to server.ts --- app/discord/gateway.ts | 68 ++++++++++------------------------------ app/server.ts | 71 +++++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 66 deletions(-) diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index e7edb48..bced796 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,37 +1,29 @@ -import { Events, InteractionType } from "discord.js"; +import { Events, InteractionType, type Client } from "discord.js"; import { Effect } from "effect"; -import modActionLogger from "#~/commands/report/modActionLogger"; -import { startActivityTracking } from "#~/discord/activityTracker"; -import automod from "#~/discord/automod"; import { client, login } from "#~/discord/client.server"; -import { deployCommands, matchCommand } from "#~/discord/deployCommands.server"; -import { startEscalationResolver } from "#~/discord/escalationResolver"; -import onboardGuild from "#~/discord/onboardGuild"; -import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; +import { matchCommand } from "#~/discord/deployCommands.server"; import { logEffect } from "#~/effects/observability.ts"; import { runEffect } from "#~/effects/runtime"; import { type AnyCommand } from "#~/helpers/discord.ts"; import { botStats } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; +import { log } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; -import { startHoneypotTracking } from "./honeypotTracker"; - // Track if gateway is already initialized to prevent duplicate logins during HMR // Use globalThis so the flag persists across module reloads declare global { var __discordGatewayInitialized: boolean | undefined; } -export const initDiscordBot: Effect.Effect = Effect.gen(function* () { +export const initDiscordBot: Effect.Effect = Effect.gen(function* () { if (globalThis.__discordGatewayInitialized) { yield* logEffect( "info", "Gateway", "Gateway already initialized, skipping duplicate init", ); - return; + return client; } yield* logEffect("info", "Gateway", "Initializing Discord gateway"); @@ -52,45 +44,6 @@ export const initDiscordBot: Effect.Effect = Effect.gen(function* () { }, ); - client.on(Events.ClientReady, async () => { - await trackPerformance( - "gateway_startup", - async () => { - log("info", "Gateway", "Bot ready event triggered", { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }); - - await Promise.all([ - onboardGuild(client), - automod(client), - modActionLogger(client), - deployCommands(client), - startActivityTracking(client), - startHoneypotTracking(client), - startReactjiChanneler(client), - ]); - - // Start escalation resolver scheduler (must be after client is ready) - startEscalationResolver(client); - - log("info", "Gateway", "Gateway initialization completed", { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }); - - // Track bot startup in business analytics - botStats.botStarted(client.guilds.cache.size, client.users.cache.size); - }, - { - guildCount: client.guilds.cache.size, - userCount: client.users.cache.size, - }, - ); - }); - - // client.on(Events.messageReactionAdd, () => {}); - client.on(Events.ThreadCreate, (thread) => { log("info", "Gateway", "Thread created", { threadId: thread.id, @@ -179,4 +132,15 @@ export const initDiscordBot: Effect.Effect = Effect.gen(function* () { // Track reconnections in business analytics botStats.reconnection(client.guilds.cache.size, client.users.cache.size); }); + + // Wait for the client to be ready before continuing + const waitForReady = Effect.async((resume) => { + client.once(Events.ClientReady, () => { + resume(Effect.succeed(client)); + }); + }); + + yield* waitForReady; + + return client; }); diff --git a/app/server.ts b/app/server.ts index 929d37b..5be4a03 100644 --- a/app/server.ts +++ b/app/server.ts @@ -11,18 +11,30 @@ import { createRequestHandler } from "@react-router/express"; import { EscalationCommands } from "#~/commands/escalationControls"; import { Command as forceBan } from "#~/commands/force-ban"; import { Command as report } from "#~/commands/report"; +import modActionLogger from "#~/commands/report/modActionLogger"; import { Command as setup } from "#~/commands/setup"; import { Command as setupHoneypot } from "#~/commands/setupHoneypot"; import { Command as setupReactjiChannel } from "#~/commands/setupReactjiChannel"; import { Command as setupTicket } from "#~/commands/setupTickets"; import { Command as track } from "#~/commands/track"; -import { registerCommand } from "#~/discord/deployCommands.server"; +import { startActivityTracking } from "#~/discord/activityTracker"; +import automod from "#~/discord/automod"; +import { + deployCommands, + registerCommand, +} from "#~/discord/deployCommands.server"; +import { startEscalationResolver } from "#~/discord/escalationResolver"; import { initDiscordBot } from "#~/discord/gateway"; +import onboardGuild from "#~/discord/onboardGuild"; +import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; import { applicationKey } from "#~/helpers/env.server"; import { runtime } from "./AppRuntime"; import { checkpointWal, runIntegrityCheck } from "./Database"; +import { startHoneypotTracking } from "./discord/honeypotTracker"; +import { DiscordApiError } from "./effects/errors"; import { logEffect } from "./effects/observability"; +import { botStats } from "./helpers/metrics"; export const app = express(); @@ -63,21 +75,52 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { }); const startup = Effect.gen(function* () { - yield* logEffect("debug", "Server", "startup init"); - yield* initDiscordBot; - yield* logEffect("debug", "Server", "scheduling integrity check"); - yield* runtime.runFork(runIntegrityCheck); - yield* logEffect("debug", "Server", "initializing commands"); - yield* registerCommand(setup); - yield* registerCommand(report); - yield* registerCommand(forceBan); - yield* registerCommand(track); - yield* registerCommand(setupTicket); - yield* registerCommand(setupReactjiChannel); - yield* registerCommand(EscalationCommands); - yield* registerCommand(setupHoneypot); + yield* Effect.all([ + registerCommand(setup), + registerCommand(report), + registerCommand(forceBan), + registerCommand(track), + registerCommand(setupTicket), + registerCommand(setupReactjiChannel), + registerCommand(EscalationCommands), + registerCommand(setupHoneypot), + ]); + + yield* logEffect("debug", "Server", "initializing Discord bot"); + const discordClient = yield* initDiscordBot; + + yield* Effect.tryPromise({ + try: () => + Promise.allSettled([ + onboardGuild(discordClient), + automod(discordClient), + modActionLogger(discordClient), + deployCommands(discordClient), + startActivityTracking(discordClient), + startHoneypotTracking(discordClient), + startReactjiChanneler(discordClient), + ]), + catch: (error) => new DiscordApiError({ operation: "init", cause: error }), + }); + + // Start escalation resolver scheduler (must be after client is ready) + startEscalationResolver(discordClient); + + yield* logEffect("info", "Gateway", "Gateway initialization completed", { + guildCount: discordClient.guilds.cache.size, + userCount: discordClient.users.cache.size, + }); + + // Track bot startup in business analytics + botStats.botStarted( + discordClient.guilds.cache.size, + discordClient.users.cache.size, + ); + + yield* logEffect("debug", "Server", "scheduling integrity check"); + yield* runtime.runFork(runIntegrityCheck); // Graceful shutdown handler to checkpoint WAL and dispose the runtime // (tears down PostHog finalizer, feature flag interval, and SQLite connection) From 95c009e01faa4fccf7fc50faa4a0976b811ce91e Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 19:14:27 -0500 Subject: [PATCH 8/9] Add missing intent holy shit --- app/discord/client.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/discord/client.server.ts b/app/discord/client.server.ts index 63b1397..f721157 100644 --- a/app/discord/client.server.ts +++ b/app/discord/client.server.ts @@ -8,6 +8,7 @@ export const client = new Client({ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildEmojisAndStickers, + GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildModeration, From fa00a777d7de415198ff38b47259bf052a819311 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 19:27:17 -0500 Subject: [PATCH 9/9] Drain metrics again on shutdown --- app/server.ts | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/app/server.ts b/app/server.ts index 5be4a03..406f233 100644 --- a/app/server.ts +++ b/app/server.ts @@ -34,7 +34,7 @@ import { checkpointWal, runIntegrityCheck } from "./Database"; import { startHoneypotTracking } from "./discord/honeypotTracker"; import { DiscordApiError } from "./effects/errors"; import { logEffect } from "./effects/observability"; -import { botStats } from "./helpers/metrics"; +import { botStats, shutdownMetrics } from "./helpers/metrics"; export const app = express(); @@ -125,22 +125,25 @@ const startup = Effect.gen(function* () { // Graceful shutdown handler to checkpoint WAL and dispose the runtime // (tears down PostHog finalizer, feature flag interval, and SQLite connection) const handleShutdown = (signal: string) => - runtime - .runPromise( - Effect.gen(function* () { - yield* logEffect("info", "Server", `Received ${signal}`); - try { - yield* checkpointWal(); - yield* logEffect("info", "Server", "Database WAL checkpointed"); - } catch (e) { - yield* logEffect("error", "Server", "Error checkpointing WAL", { - error: String(e), - }); - } - process.exit(0); - }), - ) - .then(() => runtime.dispose().then(() => console.log("ok"))); + Promise.all([ + shutdownMetrics(), + runtime + .runPromise( + Effect.gen(function* () { + yield* logEffect("info", "Server", `Received ${signal}`); + try { + yield* checkpointWal(); + yield* logEffect("info", "Server", "Database WAL checkpointed"); + } catch (e) { + yield* logEffect("error", "Server", "Error checkpointing WAL", { + error: String(e), + }); + } + process.exit(0); + }), + ) + .then(() => runtime.dispose().then(() => console.log("ok"))), + ]); yield* logEffect("debug", "Server", "setting signal handlers"); process.on("SIGTERM", () => void handleShutdown("SIGTERM"));