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,
},