deploy #1
@@ -1,7 +1,7 @@
|
|||||||
import type { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared';
|
import type { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared';
|
||||||
import type { Giveaway } from '@prisma/client';
|
import type { Giveaway } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { addJobAndAwait, giveawayQueue, giveawayQueueEvents } from '../queues';
|
import { addJobAndAwait, getGiveawayQueue, getGiveawayQueueEvents } from '../queues';
|
||||||
|
|
||||||
export class GiveawayCreateTimeoutError extends Error {
|
export class GiveawayCreateTimeoutError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -60,8 +60,8 @@ export async function createGiveawayDashboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { confirmed, result } = await addJobAndAwait<{ id: string }>(
|
const { confirmed, result } = await addJobAndAwait<{ id: string }>(
|
||||||
giveawayQueue,
|
getGiveawayQueue(),
|
||||||
giveawayQueueEvents,
|
getGiveawayQueueEvents(),
|
||||||
'giveawayCreate',
|
'giveawayCreate',
|
||||||
{
|
{
|
||||||
guildId,
|
guildId,
|
||||||
@@ -112,12 +112,12 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingJob = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
|
||||||
await existingJob?.remove();
|
await existingJob?.remove();
|
||||||
|
|
||||||
await addJobAndAwait(
|
await addJobAndAwait(
|
||||||
giveawayQueue,
|
getGiveawayQueue(),
|
||||||
giveawayQueueEvents,
|
getGiveawayQueueEvents(),
|
||||||
'giveawayEnd',
|
'giveawayEnd',
|
||||||
{ giveawayId },
|
{ giveawayId },
|
||||||
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }
|
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }
|
||||||
@@ -133,7 +133,7 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
const job = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
|
||||||
await job?.remove();
|
await job?.remove();
|
||||||
|
|
||||||
await prisma.giveaway.delete({ where: { id: giveawayId } });
|
await prisma.giveaway.delete({ where: { id: giveawayId } });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { GuildBackup } from '@prisma/client';
|
|||||||
import { buildGuildBackupPayloadViaRest } from '../guild-backup';
|
import { buildGuildBackupPayloadViaRest } from '../guild-backup';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
import { assertPremiumLimit, PremiumLimitError } from '../premium';
|
||||||
import { guildBackupQueue, guildBackupQueueName } from '../queues';
|
import { getGuildBackupQueue, guildBackupQueueName } from '../queues';
|
||||||
|
|
||||||
export class BackupPremiumError extends Error {
|
export class BackupPremiumError extends Error {
|
||||||
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
|
constructor(public readonly code: 'premium_limit' | 'premium_feature') {
|
||||||
@@ -77,7 +77,7 @@ export async function enqueueGuildBackupRestore(
|
|||||||
guildId: string,
|
guildId: string,
|
||||||
requestedById: string
|
requestedById: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await guildBackupQueue.add(
|
await getGuildBackupQueue().add(
|
||||||
'restoreGuildBackup',
|
'restoreGuildBackup',
|
||||||
{ backupId, guildId, requestedById },
|
{ backupId, guildId, requestedById },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
|
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
|
||||||
import type { ScheduledMessage } from '@prisma/client';
|
import type { ScheduledMessage } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { scheduleQueue } from '../queues';
|
import { getScheduleQueue } from '../queues';
|
||||||
|
|
||||||
export class SchedulerValidationError extends Error {
|
export class SchedulerValidationError extends Error {
|
||||||
constructor(public readonly code: string) {
|
constructor(public readonly code: string) {
|
||||||
@@ -92,7 +92,7 @@ export async function createScheduledMessageDashboard(
|
|||||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
const job = await getScheduleQueue().add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
||||||
const updated = await prisma.scheduledMessage.update({
|
const updated = await prisma.scheduledMessage.update({
|
||||||
where: { id: schedule.id },
|
where: { id: schedule.id },
|
||||||
data: { jobId: String(job.id) }
|
data: { jobId: String(job.id) }
|
||||||
@@ -108,7 +108,7 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existing.jobId) {
|
if (existing.jobId) {
|
||||||
const job = await scheduleQueue.getJob(existing.jobId);
|
const job = await getScheduleQueue().getJob(existing.jobId);
|
||||||
await job?.remove();
|
await job?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { Suggestion } from '@prisma/client';
|
import type { Suggestion } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { addJobAndAwait, suggestionsQueue, suggestionsQueueEvents } from '../queues';
|
import { addJobAndAwait, getSuggestionsQueue, getSuggestionsQueueEvents } from '../queues';
|
||||||
|
|
||||||
export class SuggestionActionTimeoutError extends Error {
|
export class SuggestionActionTimeoutError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -105,8 +105,8 @@ export async function applySuggestionAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { confirmed } = await addJobAndAwait<{ id: string }>(
|
const { confirmed } = await addJobAndAwait<{ id: string }>(
|
||||||
suggestionsQueue,
|
getSuggestionsQueue(),
|
||||||
suggestionsQueueEvents,
|
getSuggestionsQueueEvents(),
|
||||||
'suggestionStatusUpdate',
|
'suggestionStatusUpdate',
|
||||||
{
|
{
|
||||||
suggestionId,
|
suggestionId,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { redis } from './redis';
|
|||||||
* drawing winners, restoring a guild backup) is enqueued here and picked up
|
* drawing winners, restoring a guild backup) is enqueued here and picked up
|
||||||
* by the bot's existing workers - the WebUI never talks to Discord directly
|
* by the bot's existing workers - the WebUI never talks to Discord directly
|
||||||
* for these actions.
|
* for these actions.
|
||||||
|
*
|
||||||
|
* Queues and QueueEvents are created lazily on first use. Eager construction
|
||||||
|
* at import time would open Redis connections during `next build` (where
|
||||||
|
* Redis is intentionally unavailable), flooding the build log with
|
||||||
|
* ECONNREFUSED errors.
|
||||||
*/
|
*/
|
||||||
export const giveawayQueueName = 'giveaways';
|
export const giveawayQueueName = 'giveaways';
|
||||||
export const scheduleQueueName = 'schedules';
|
export const scheduleQueueName = 'schedules';
|
||||||
@@ -24,43 +29,68 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const giveawayQueue: Queue =
|
function queueConnection() {
|
||||||
globalForQueues.__nexumiGiveawayQueue ?? new Queue(giveawayQueueName, { connection: redis });
|
return redis;
|
||||||
|
}
|
||||||
|
|
||||||
export const scheduleQueue: Queue =
|
/** QueueEvents needs its own connection; duplicate inherits lazyConnect. */
|
||||||
globalForQueues.__nexumiScheduleQueue ?? new Queue(scheduleQueueName, { connection: redis });
|
function queueEventsConnection() {
|
||||||
|
return redis.duplicate();
|
||||||
|
}
|
||||||
|
|
||||||
export const guildBackupQueue: Queue =
|
export function getGiveawayQueue(): Queue {
|
||||||
globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis });
|
globalForQueues.__nexumiGiveawayQueue ??= new Queue(giveawayQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiGiveawayQueue;
|
||||||
|
}
|
||||||
|
|
||||||
export const suggestionsQueue: Queue =
|
export function getScheduleQueue(): Queue {
|
||||||
globalForQueues.__nexumiSuggestionsQueue ?? new Queue(suggestionsQueueName, { connection: redis });
|
globalForQueues.__nexumiScheduleQueue ??= new Queue(scheduleQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiScheduleQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGuildBackupQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiGuildBackupQueue ??= new Queue(guildBackupQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiGuildBackupQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestionsQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiSuggestionsQueue ??= new Queue(suggestionsQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiSuggestionsQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
* is immediately visible). They open their own Redis connection, so they are
|
* is immediately visible).
|
||||||
* cached on the global object the same way as the queues themselves.
|
|
||||||
*/
|
*/
|
||||||
export const giveawayQueueEvents: QueueEvents =
|
export function getGiveawayQueueEvents(): QueueEvents {
|
||||||
globalForQueues.__nexumiGiveawayQueueEvents ??
|
globalForQueues.__nexumiGiveawayQueueEvents ??= new QueueEvents(giveawayQueueName, {
|
||||||
new QueueEvents(giveawayQueueName, { connection: redis.duplicate() });
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiGiveawayQueueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
export const guildBackupQueueEvents: QueueEvents =
|
export function getGuildBackupQueueEvents(): QueueEvents {
|
||||||
globalForQueues.__nexumiGuildBackupQueueEvents ??
|
globalForQueues.__nexumiGuildBackupQueueEvents ??= new QueueEvents(guildBackupQueueName, {
|
||||||
new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() });
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiGuildBackupQueueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
export const suggestionsQueueEvents: QueueEvents =
|
export function getSuggestionsQueueEvents(): QueueEvents {
|
||||||
globalForQueues.__nexumiSuggestionsQueueEvents ??
|
globalForQueues.__nexumiSuggestionsQueueEvents ??= new QueueEvents(suggestionsQueueName, {
|
||||||
new QueueEvents(suggestionsQueueName, { connection: redis.duplicate() });
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
globalForQueues.__nexumiGiveawayQueue = giveawayQueue;
|
return globalForQueues.__nexumiSuggestionsQueueEvents;
|
||||||
globalForQueues.__nexumiScheduleQueue = scheduleQueue;
|
}
|
||||||
globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue;
|
|
||||||
globalForQueues.__nexumiSuggestionsQueue = suggestionsQueue;
|
|
||||||
globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents;
|
|
||||||
globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents;
|
|
||||||
globalForQueues.__nexumiSuggestionsQueueEvents = suggestionsQueueEvents;
|
|
||||||
|
|
||||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { env } from './env';
|
|||||||
|
|
||||||
const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis };
|
const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis };
|
||||||
|
|
||||||
|
/** Next.js sets this while collecting page data / compiling the production build. */
|
||||||
|
function isNextProductionBuild(): boolean {
|
||||||
|
return process.env.NEXT_PHASE === 'phase-production-build';
|
||||||
|
}
|
||||||
|
|
||||||
export const redis: Redis =
|
export const redis: Redis =
|
||||||
globalForRedis.__nexumiRedis ??
|
globalForRedis.__nexumiRedis ??
|
||||||
new Redis(env.REDIS_URL, {
|
new Redis(env.REDIS_URL, {
|
||||||
@@ -16,7 +21,17 @@ export const redis: Redis =
|
|||||||
});
|
});
|
||||||
|
|
||||||
redis.on('error', (error) => {
|
redis.on('error', (error) => {
|
||||||
console.error('[redis]', error.message);
|
// Redis is intentionally unreachable during `next build` (Docker build
|
||||||
|
// stage uses a placeholder REDIS_URL). Queues are lazy, but any stray
|
||||||
|
// connect attempt should stay silent rather than spam AggregateError.
|
||||||
|
if (isNextProductionBuild()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message || ('code' in error ? String((error as NodeJS.ErrnoException).code) : error.name)
|
||||||
|
: String(error);
|
||||||
|
console.error('[redis]', detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (env.NODE_ENV !== 'production') {
|
if (env.NODE_ENV !== 'production') {
|
||||||
|
|||||||
Reference in New Issue
Block a user