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
89 changes: 82 additions & 7 deletions app/AppRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect, Layer, Logger, LogLevel, ManagedRuntime } from "effect";
import type { PostHog } from "posthog-node";

import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database";
import { NotFoundError } from "#~/effects/errors";
import {
FeatureFlagService,
FeatureFlagServiceLive,
type BooleanFlag,
} from "#~/effects/featureFlags";
import { PostHogService, PostHogServiceLive } from "#~/effects/posthog";
import { TracingLive } from "#~/effects/tracing.js";
import { isProd } from "#~/helpers/env.server.js";

// App layer: database + PostHog + feature flags
// FeatureFlagServiceLive depends on both DatabaseService and PostHogService
const AppLayer = Layer.mergeAll(DatabaseLayer);
// Infrastructure layer: tracing + structured logging + prod log level
const InfraLayer = isProd()
? Layer.mergeAll(
TracingLive,
Logger.json,
Logger.minimumLogLevel(LogLevel.Info),
)
: Layer.mergeAll(TracingLive, Logger.json);

// App layer: database + PostHog + feature flags + infrastructure
const AppLayer = Layer.mergeAll(
DatabaseLayer,
PostHogServiceLive,
Layer.provide(
FeatureFlagServiceLive,
Layer.mergeAll(DatabaseLayer, PostHogServiceLive),
),
InfraLayer,
);

// ManagedRuntime keeps the AppLayer scope alive for the process lifetime.
// Unlike Effect.runSync which closes the scope (and thus the SQLite connection)
Expand All @@ -19,20 +44,35 @@ export type RuntimeContext = ManagedRuntime.ManagedRuntime.Context<
>;

// Extract the PostHog client for use by metrics.ts (null when no API key configured).
export const db: EffectKysely = await runtime.runPromise(DatabaseService);
export const [posthogClient, db]: [PostHog | null, EffectKysely] =
await Promise.all([
runtime.runPromise(PostHogService),
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.
/**
* Convenience helpers for legacy async/await code that needs to run
* EffectKysely query builders as Promises.
*
* @deprecated
* @param effect
*/
export const run = <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> =>
Effect.runPromise(effect);

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

/**
* @deprecated
*/
export const runTakeFirstOrThrow = <A>(
effect: Effect.Effect<A[], unknown, never>,
): Promise<A> =>
Expand All @@ -43,3 +83,38 @@ export const runTakeFirstOrThrow = <A>(
: Effect.fail(new NotFoundError({ resource: "db record", id: "" })),
),
);

// Run an Effect through the ManagedRuntime, returning a Promise.
export const runEffect = <A, E>(
effect: Effect.Effect<A, E, RuntimeContext>,
): Promise<A> => runtime.runPromise(effect);

// Run an Effect through the ManagedRuntime, returning a Promise<Exit>.
export const runEffectExit = <A, E>(
effect: Effect.Effect<A, E, RuntimeContext>,
) => runtime.runPromiseExit(effect);

/**
* Run an effect only if the specified feature flag is enabled for the guild.
* Returns void if the flag is disabled, otherwise returns the effect result.
*/
export const runGatedFeature = <A>(
flag: BooleanFlag,
guildId: string,
effect: Effect.Effect<A, unknown, RuntimeContext>,
): Promise<A | void> =>
runtime.runPromise(
Effect.gen(function* () {
const flags = yield* FeatureFlagService;
const enabled = yield* flags.isPostHogEnabled(flag, guildId);
if (!enabled) {
posthogClient?.capture({
distinctId: guildId,
event: "premium gate hit",
properties: { flag, $groups: { guild: guildId } },
});
return;
}
return yield* effect;
}),
);
5 changes: 5 additions & 0 deletions app/commands/escalate/escalate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sendMessage,
} from "#~/effects/discordSdk";
import { DiscordApiError } from "#~/effects/errors";
import { FeatureFlagService, guardFeature } from "#~/effects/featureFlags";
import { logEffect } from "#~/effects/observability";
import { calculateScheduledFor } from "#~/helpers/escalationVotes";
import type { Features } from "#~/helpers/featuresFlags";
Expand Down Expand Up @@ -41,6 +42,10 @@ export const createEscalationEffect = (
Effect.gen(function* () {
const escalationService = yield* EscalationService;
const guildId = interaction.guildId!;

// Check if escalation feature is enabled for this guild
const flags = yield* FeatureFlagService;
yield* guardFeature(flags, "escalate", guildId);
const threadId = interaction.channelId;
const features: Features[] = [];

Expand Down
5 changes: 5 additions & 0 deletions app/commands/escalate/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ const escalate = (interaction: MessageComponentInteraction) =>
attributes: { guildId: interaction.guildId, userId: interaction.user.id },
}),
Effect.provide(EscalationServiceLive),
Effect.catchTag("FeatureDisabledError", () =>
interactionEditReply(interaction, {
content: "This is a paid feature. Upgrade with `/upgrade`",
}).pipe(Effect.catchAll(() => Effect.void)),
),
Effect.catchTag("NotFoundError", () =>
interactionEditReply(interaction, {
content: "Failed to re-escalate, couldn't find escalation",
Expand Down
2 changes: 1 addition & 1 deletion app/commands/report/modActionLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import {
} from "discord.js";
import { Effect } from "effect";

import { runEffect } from "#~/AppRuntime";
import { logAutomod } from "#~/commands/report/automodLog.ts";
import { fetchUser } from "#~/effects/discordSdk.ts";
import { logEffect } from "#~/effects/observability.ts";
import { runEffect } from "#~/effects/runtime.ts";

import { logModAction } from "./modActionLog";

Expand Down
2 changes: 1 addition & 1 deletion app/commands/report/userLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import {
} from "discord.js";
import { Effect } from "effect";

import { runEffect } from "#~/AppRuntime";
import { type DatabaseService, type SqlError } from "#~/Database";
import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk.ts";
import { DiscordApiError, type NotFoundError } from "#~/effects/errors";
import { logEffect } from "#~/effects/observability";
import { runEffect } from "#~/effects/runtime";
import {
describeAttachments,
describeReactions,
Expand Down
Loading