Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/AppRuntime.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +6 to +8
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions "database + PostHog + feature flags" but the AppLayer only merges DatabaseLayer. If PostHog and feature flags are intended to be part of this layer, they're missing from the implementation. If they're not yet implemented, the comment should be updated to reflect the current state.

Copilot uses AI. Check for mistakes.

// 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 = <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> =>
Effect.runPromise(effect);

export const runTakeFirst = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A | undefined> =>
Effect.runPromise(Effect.map(effect, (rows) => rows[0]));

export const runTakeFirstOrThrow = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A> =>
Effect.runPromise(
Effect.flatMap(effect, (rows) =>
rows[0] !== undefined
? Effect.succeed(rows[0])
: Effect.fail(new NotFoundError({ resource: "db record", id: "" })),
),
);
113 changes: 25 additions & 88 deletions app/Database.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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<DB>()).pipe(
Expand All @@ -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 = <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> =>
Effect.runPromise(effect);

export const runTakeFirst = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A | undefined> =>
Effect.runPromise(Effect.map(effect, (rows) => rows[0]));

export const runTakeFirstOrThrow = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A> =>
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: () =>
Expand Down Expand Up @@ -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
});
});
}
2 changes: 1 addition & 1 deletion app/discord/activityTracker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
1 change: 1 addition & 0 deletions app/discord/client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const client = new Client({
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildEmojisAndStickers,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildModeration,
Expand Down
30 changes: 21 additions & 9 deletions app/discord/deployCommands.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type OAuth2Guild,
type SlashCommandBuilder,
} from "discord.js";
import { Effect } from "effect";

import { ssrDiscordSdk } from "#~/discord/api";
import {
Expand Down Expand Up @@ -178,15 +179,26 @@ export const deployTestCommands = async (
};

const commands = new Map<string, AnyCommand>();
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<void> =>
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) {
Expand Down
94 changes: 20 additions & 74 deletions app/discord/gateway.ts
Original file line number Diff line number Diff line change
@@ -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<Client> = 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();
Expand All @@ -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,
Expand Down Expand Up @@ -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<Client>((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;
});
2 changes: 1 addition & 1 deletion app/discord/honeypotTracker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading