Skip to content
Draft
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
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
- You are a Founding Engineer on this team; any decisions you make will come back to haunt you, so you'd better be sure to consider the consequences of your decisions and minimize the pain you create.
- Retain worklogs in `/notes`, documenting decisions made. No fluff; low noise/high signal. You're a staff engineer writing for your peers.
- Note filenames MUST follow the format `<date>_<X>_<title>.md`, i.e. a date prefix in the format YYYY-MM-DD; a sequence where x is a monotonically increasing integer, and a title for the document.
- Commit periodically.
- This project uses sqlite, so you can inspect the database yourself. You can make your own dummy data, but don't do anything destructive.
- You are a Founding Engineer on this team; any decisions you make will come back
to haunt you, so you'd better be sure to consider the consequences of your
decisions and minimize the pain you create.
- Write your thoughts in `/notes`, especially if it will help you remember
important implementation details later.
- Your notes must be named consistently with a date prefix in the format
`YYYY-MM-DD_X_title.md` where X is a monotonically increasing integer.
- This project uses sqlite at `./mod-bot.sqlite3`, so you can inspect the database
yourself.
- Prefer using your Playwright MCP over curl.
- If touching Effect-TS code, consult @notes/EFFECT.md.

When starting a new project, always read the README.md file in the root
directory.
42 changes: 42 additions & 0 deletions app/effects/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Data } from "effect";

// Tagged error types for discriminated unions
// Each error has a _tag property for pattern matching with Effect.catchTag

export class DiscordApiError extends Data.TaggedError("DiscordApiError")<{
operation: string;
discordError: unknown;
}> {}

export class DatabaseError extends Data.TaggedError("DatabaseError")<{
operation: string;
cause: unknown;
}> {}

export class DatabaseConstraintError extends Data.TaggedError(
"DatabaseConstraintError",
)<{
operation: string;
constraint: string;
cause: unknown;
}> {}

export class StripeApiError extends Data.TaggedError("StripeApiError")<{
operation: string;
stripeError: unknown;
}> {}

export class NotFoundError extends Data.TaggedError("NotFoundError")<{
resource: string;
id: string;
}> {}

export class ValidationError extends Data.TaggedError("ValidationError")<{
field: string;
message: string;
}> {}

export class ConfigError extends Data.TaggedError("ConfigError")<{
key: string;
message: string;
}> {}
147 changes: 147 additions & 0 deletions app/effects/models/userThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Effect } from "effect";
import type { Selectable } from "kysely";

import { type DB } from "#~/db.server.js";

import { logEffect } from "../observability.js";
import { runEffect } from "../runtime.js";
// =============================================================================
// Legacy wrappers for backward compatibility
// These allow existing code to use the Effect-based functions without changes.
// =============================================================================
import {
DatabaseService,
DatabaseServiceLive,
db,
} from "../services/Database.js";

// Use Selectable to get the type that Kysely returns from queries
export type UserThread = Selectable<DB["user_threads"]>;

/**
* Get a user's thread for a specific guild.
* Returns undefined if no thread exists.
*/
export const getUserThread = (userId: string, guildId: string) =>
Effect.gen(function* () {
const dbService = yield* DatabaseService;

const thread = yield* dbService.query(
() =>
db
.selectFrom("user_threads")
.selectAll()
.where("user_id", "=", userId)
.where("guild_id", "=", guildId)
.executeTakeFirst(),
"getUserThread",
);

yield* logEffect(
"debug",
"UserThread",
thread ? "Found user thread" : "No user thread found",
{ userId, guildId, threadId: thread?.thread_id },
);

return thread;
}).pipe(Effect.withSpan("getUserThread", { attributes: { userId, guildId } }));

/**
* Create a new user thread record.
*/
export const createUserThread = (
userId: string,
guildId: string,
threadId: string,
) =>
Effect.gen(function* () {
const dbService = yield* DatabaseService;

yield* dbService.query(
() =>
db
.insertInto("user_threads")
.values({
user_id: userId,
guild_id: guildId,
thread_id: threadId,
})
.execute(),
"createUserThread",
);

yield* logEffect("debug", "UserThread", "Created user thread", {
userId,
guildId,
threadId,
});
}).pipe(
Effect.withSpan("createUserThread", { attributes: { userId, guildId, threadId } }),
);

/**
* Update an existing user thread record.
*/
export const updateUserThread = (
userId: string,
guildId: string,
threadId: string,
) =>
Effect.gen(function* () {
const dbService = yield* DatabaseService;

yield* dbService.query(
() =>
db
.updateTable("user_threads")
.set({ thread_id: threadId })
.where("user_id", "=", userId)
.where("guild_id", "=", guildId)
.execute(),
"updateUserThread",
);

yield* logEffect("debug", "UserThread", "Updated user thread", {
userId,
guildId,
threadId,
});
}).pipe(
Effect.withSpan("updateUserThread", { attributes: { userId, guildId, threadId } }),
);

/**
* Provide the database service layer to an effect and run it.
*/
const runWithDb = <A, E>(effect: Effect.Effect<A, E, DatabaseService>) =>
runEffect(Effect.provide(effect, DatabaseServiceLive));

/**
* Legacy wrapper for getUserThread.
* @deprecated Use the Effect-based version directly when possible.
*/
export const getUserThreadLegacy = (
userId: string,
guildId: string,
): Promise<UserThread | undefined> => runWithDb(getUserThread(userId, guildId));

/**
* Legacy wrapper for createUserThread.
* @deprecated Use the Effect-based version directly when possible.
*/
export const createUserThreadLegacy = (
userId: string,
guildId: string,
threadId: string,
): Promise<void> => runWithDb(createUserThread(userId, guildId, threadId));

/**
* Legacy wrapper for updateUserThread.
* @deprecated Use the Effect-based version directly when possible.
*/
export const updateUserThreadLegacy = (
userId: string,
guildId: string,
threadId: string,
): Promise<void> => runWithDb(updateUserThread(userId, guildId, threadId));
29 changes: 29 additions & 0 deletions app/effects/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Effect } from "effect";

import { log as legacyLog } from "#~/helpers/observability.js";

/**
* Bridge Effect logging to existing observability infrastructure.
* Returns an Effect that performs the logging as a side effect.
*/
export const logEffect = (
level: "debug" | "info" | "warn" | "error",
service: string,
message: string,
context: Record<string, unknown> = {},
): Effect.Effect<void, never, never> =>
Effect.sync(() => legacyLog(level, service, message, context));

/**
* Log and continue - useful for adding logging to a pipeline without affecting the flow.
* Uses Effect.tap to perform logging as a side effect.
*/
export const tapLog =
<A>(
level: "debug" | "info" | "warn" | "error",
service: string,
message: string,
getContext: (a: A) => Record<string, unknown> = () => ({}),
) =>
<E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.tap(self, (a) => logEffect(level, service, message, getContext(a)));
36 changes: 36 additions & 0 deletions app/effects/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Effect } from "effect";

import { TracingLive } from "./tracing.js";

/**
* Runtime helpers for running Effects in the Promise-based codebase.
* These provide the bridge between Effect-based code and legacy async/await code.
*
* The tracing layer is automatically provided to all effects run through these
* helpers, so spans created with Effect.withSpan will be exported to Sentry.
*/

/**
* Run an Effect and return a Promise that resolves with the success value.
* Automatically provides the tracing layer for Sentry integration.
* Throws if the Effect fails.
*/
export const runEffect = <A, E>(
effect: Effect.Effect<A, E, never>,
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(TracingLive)));

/**
* Run an Effect and return a Promise that resolves with an Exit value.
* Automatically provides the tracing layer for Sentry integration.
* Never throws - use this when you need to inspect failures.
*/
export const runEffectExit = <A, E>(effect: Effect.Effect<A, E, never>) =>
Effect.runPromiseExit(effect.pipe(Effect.provide(TracingLive)));

/**
* Run an Effect synchronously.
* Note: Tracing is not provided for sync execution - use runEffect for traced effects.
* Only use for Effects that are guaranteed to be synchronous.
*/
export const runEffectSync = <A, E>(effect: Effect.Effect<A, E, never>): A =>
Effect.runSync(effect);
84 changes: 84 additions & 0 deletions app/effects/services/Database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Context, Effect, Layer } from "effect";

import db from "#~/db.server.js";

import { DatabaseConstraintError, DatabaseError } from "../errors.js";

/**
* Database service interface for Effect-based database operations.
* Wraps Kysely queries in Effects with typed errors.
*/
export interface IDatabaseService {
/**
* Execute a database query and wrap the result in an Effect.
* Converts promise rejections to DatabaseError.
*/
readonly query: <T>(
fn: () => Promise<T>,
operation: string,
) => Effect.Effect<T, DatabaseError, never>;

/**
* Execute a database query that may fail with a constraint violation.
* Returns a discriminated union of success/constraint error.
*/
readonly queryWithConstraint: <T>(
fn: () => Promise<T>,
operation: string,
constraintName: string,
) => Effect.Effect<T, DatabaseError | DatabaseConstraintError, never>;
}

export class DatabaseService extends Context.Tag("DatabaseService")<
DatabaseService,
IDatabaseService
>() {}

/**
* Check if an error is a SQLite constraint violation
*/
const isConstraintError = (error: unknown, constraintName: string): boolean => {
if (error instanceof Error) {
return error.message.includes(
`UNIQUE constraint failed: ${constraintName}`,
);
}
return false;
};

/**
* Live implementation of the DatabaseService.
* Uses the global Kysely db instance.
*/
export const DatabaseServiceLive = Layer.succeed(DatabaseService, {
query: <T>(fn: () => Promise<T>, operation: string) =>
Effect.tryPromise({
try: fn,
catch: (error) => new DatabaseError({ operation, cause: error }),
}),

queryWithConstraint: <T>(
fn: () => Promise<T>,
operation: string,
constraintName: string,
) =>
Effect.tryPromise({
try: fn,
catch: (error) => {
if (isConstraintError(error, constraintName)) {
return new DatabaseConstraintError({
operation,
constraint: constraintName,
cause: error,
});
}
return new DatabaseError({ operation, cause: error });
},
}),
});

/**
* Direct access to the Kysely database instance for raw queries.
* Prefer using the service methods when possible.
*/
export { db };
29 changes: 29 additions & 0 deletions app/effects/tracing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NodeSdk } from "@effect/opentelemetry";
import {
SentryPropagator,
SentrySampler,
SentrySpanProcessor,
} from "@sentry/opentelemetry";

import Sentry, { isValidDsn } from "#~/helpers/sentry.server.js";

/**
* Effect OpenTelemetry layer that exports spans to Sentry.
*
* This layer integrates Effect's native tracing (Effect.withSpan) with Sentry.
* All spans created with Effect.withSpan will be exported to Sentry for
* visualization in their Performance dashboard.
*
* The layer uses:
* - SentrySpanProcessor: Exports spans to Sentry (it IS a SpanProcessor, not an exporter)
* - SentrySampler: Respects Sentry's tracesSampleRate
* - SentryPropagator: Enables distributed tracing
*/
export const TracingLive = NodeSdk.layer(() => ({
resource: { serviceName: "mod-bot" },
// Only add Sentry processors if Sentry is configured
// SentrySpanProcessor is already a SpanProcessor, don't wrap in BatchSpanProcessor
spanProcessor: isValidDsn ? new SentrySpanProcessor() : undefined,
sampler: isValidDsn ? new SentrySampler(Sentry.getClient()!) : undefined,
propagator: isValidDsn ? new SentryPropagator() : undefined,
}));
Loading
Loading