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/Database.ts b/app/Database.ts index 0f80a44..da7265a 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,17 @@ 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.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 - }); - }); -} 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/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, diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 9ae7529..956a330 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,26 @@ 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); + }).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 7bd4c9f..bced796 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,39 +1,32 @@ -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 { shutdownDatabase, startIntegrityCheck } from "#~/Database"; -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, shutdownMetrics } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; +import { botStats } from "#~/helpers/metrics"; +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 default function init() { +export const initDiscordBot: Effect.Effect = Effect.gen(function* () { if (globalThis.__discordGatewayInitialized) { - log( + yield* logEffect( "info", "Gateway", "Gateway already initialized, skipping duplicate init", ); - return; + return client; } - log("info", "Gateway", "Initializing Discord gateway"); + yield* logEffect("info", "Gateway", "Initializing Discord gateway"); globalThis.__discordGatewayInitialized = true; void login(); @@ -51,48 +44,6 @@ export default function init() { }, ); - 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); - - // Start twice-daily database integrity check - startIntegrityCheck(); - - 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, @@ -182,19 +133,14 @@ export default function init() { 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); - }; + // Wait for the client to be ready before continuing + const waitForReady = Effect.async((resume) => { + client.once(Events.ClientReady, () => { + resume(Effect.succeed(client)); + }); + }); - process.on("SIGTERM", () => void handleShutdown("SIGTERM")); - process.on("SIGINT", () => void handleShutdown("SIGINT")); -} + yield* waitForReady; + + return client; +}); 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"; diff --git a/app/server.ts b/app/server.ts index 18e3dcf..406f233 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"; @@ -10,15 +11,31 @@ 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 discordBot from "#~/discord/gateway"; +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, shutdownMetrics } from "./helpers/metrics"; + export const app = express(); const logger = pinoHttp(); @@ -57,19 +74,82 @@ app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { next(); }); -/** - * Initialize Discord gateway. - */ -discordBot(); - -/** - * Register Discord commands. - */ -registerCommand(setup); -registerCommand(report); -registerCommand(forceBan); -registerCommand(track); -registerCommand(setupTicket); -registerCommand(setupReactjiChannel); -registerCommand(EscalationCommands); -registerCommand(setupHoneypot); +const startup = Effect.gen(function* () { + yield* logEffect("debug", "Server", "initializing commands"); + + 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) + const handleShutdown = (signal: string) => + 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")); + process.on("SIGINT", () => void handleShutdown("SIGINT")); +}); + +console.log("running program"); +const exit = await Effect.runPromiseExit(startup); +console.log({ exit }); 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, },