diff --git a/.cursor/rules/agents.mdc b/.cursor/rules/agents.mdc new file mode 100644 index 00000000..674a902a --- /dev/null +++ b/.cursor/rules/agents.mdc @@ -0,0 +1,5 @@ +# Agent instructions + +- Use typed SQL (generated `sql` helpers with `prisma.$queryRawTyped`). +- Do not use `prisma.$queryRaw` or `Prisma.sql`. +- Exception: Prisma typed SQL filenames must be valid JS identifiers (no dashes), so the lower-kebab-case rule does not apply under `prisma/sql/`. diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index e8003a54..5919eafb 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -8,6 +8,7 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { requireUserId } from '#app/utils/auth.server.js' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { getunsentmessagecounts } from '#app/utils/prisma-generated.server/sql.ts' import { NEXT_SCHEDULE_SENTINEL_DATE, PREV_SCHEDULE_SENTINEL_DATE, @@ -36,6 +37,7 @@ function formatScheduleDisplay(date: Date, timeZone: string) { export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request) + const recipients = await prisma.recipient.findMany({ select: { id: true, @@ -46,10 +48,25 @@ export async function loader({ request }: LoaderFunctionArgs) { disabled: true, prevScheduledAt: true, nextScheduledAt: true, - _count: { select: { messages: { where: { sentAt: null } } } }, }, - where: { userId }, + where: { + userId, + }, + orderBy: { id: 'asc' }, }) + const recipientIds = recipients.map((recipient) => recipient.id) + const messageCounts = recipientIds.length + ? await prisma.$queryRawTyped( + getunsentmessagecounts(JSON.stringify(recipientIds)), + ) + : [] + const messageCountByRecipientId = new Map( + messageCounts.map((row) => [row.recipientId, Number(row.unsentCount ?? 0)]), + ) + const recipientsWithCounts = recipients.map((recipient) => ({ + ...recipient, + messageCount: messageCountByRecipientId.get(recipient.id) ?? 0, + })) const now = new Date() const scheduleUpdates: Array<{ @@ -59,7 +76,7 @@ export async function loader({ request }: LoaderFunctionArgs) { }> = [] // Ensure we have a schedule window for sorting/display - const sortedRecipients = recipients + const sortedRecipients = recipientsWithCounts .map((recipient) => { let nextScheduledAt = recipient.nextScheduledAt let prevScheduledAt = recipient.prevScheduledAt diff --git a/app/routes/_app+/recipients+/index.tsx b/app/routes/_app+/recipients+/index.tsx index 59d9dfd1..3199345c 100644 --- a/app/routes/_app+/recipients+/index.tsx +++ b/app/routes/_app+/recipients+/index.tsx @@ -67,7 +67,7 @@ export default function RecipientsIndexRoute() { {hasRecipients ? (
{recipients.map((recipient) => { - const messageCount = recipient._count.messages + const messageCount = recipient.messageCount const messageLabel = messageCount === 1 ? 'message' : 'messages' const messageText = `${messageCount} ${messageLabel}` const messagePreparedText = `${messageText} prepared` diff --git a/prisma/migrations/20260202160000-recipient-message-indexes/migration.sql b/prisma/migrations/20260202160000-recipient-message-indexes/migration.sql new file mode 100644 index 00000000..a4d2005b --- /dev/null +++ b/prisma/migrations/20260202160000-recipient-message-indexes/migration.sql @@ -0,0 +1,6 @@ +-- CreateIndex +CREATE INDEX "recipient_by_user" ON "Recipient"("userId", "id"); + +-- CreateIndex +CREATE INDEX "message_unsent_by_recipient" ON "Message"("recipientId") +WHERE "sentAt" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8fc5232..a4bad9ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,7 @@ model Recipient { // non-unique foreign key @@index([userId]) + @@index([userId, id], name: "recipient_by_user") // Optimized composite index for cron query: equality cols first, then range, then join @@index([verified, disabled, nextScheduledAt, userId], name: "Recipient_cron_query_idx") } diff --git a/prisma/sql/getunsentmessagecounts.sql b/prisma/sql/getunsentmessagecounts.sql new file mode 100644 index 00000000..551ced56 --- /dev/null +++ b/prisma/sql/getunsentmessagecounts.sql @@ -0,0 +1,10 @@ +-- Count unsent messages for a list of recipient IDs. +-- +-- @param {String} $1 - JSON array of recipient ids +SELECT + recipientId, + CAST(COUNT(*) AS INTEGER) AS unsentCount +FROM Message INDEXED BY message_unsent_by_recipient +WHERE sentAt IS NULL + AND recipientId IN (SELECT value FROM json_each($1)) +GROUP BY recipientId; diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index 1ad5949c..f993c824 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -3,10 +3,14 @@ import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { getrecipientsforcron } from '#app/utils/prisma-generated.server/sql.ts' +import { + getrecipientsforcron, + getunsentmessagecounts, +} from '#app/utils/prisma-generated.server/sql.ts' import { NEXT_SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' const MESSAGES_PER_PAGE = 100 +const RECIPIENTS_PAGE_SIZE = 200 type Summary = { min: number @@ -120,14 +124,31 @@ async function benchmarkRecipientsList( disabled: true, prevScheduledAt: true, nextScheduledAt: true, - _count: { select: { messages: { where: { sentAt: null } } } }, }, + orderBy: { id: 'asc' }, + take: RECIPIENTS_PAGE_SIZE, }) + const recipientIds = recipients.map((recipient) => recipient.id) + const messageCounts = recipientIds.length + ? await prisma.$queryRawTyped( + getunsentmessagecounts(JSON.stringify(recipientIds)), + ) + : [] + const messageCountByRecipientId = new Map( + messageCounts.map((row) => [ + row.recipientId, + Number(row.unsentCount ?? 0), + ]), + ) + const recipientsWithCounts = recipients.map((recipient) => ({ + ...recipient, + messageCount: messageCountByRecipientId.get(recipient.id) ?? 0, + })) const queryMs = performance.now() - queryStart const computeStart = performance.now() let errors = 0 - const sortedRecipients = recipients + const sortedRecipients = recipientsWithCounts .map((recipient) => { try { const isSentinel = @@ -184,7 +205,7 @@ async function benchmarkRecipientsList( querySamples.push(queryMs) computeSamples.push(computeMs) cronErrors.push(errors) - lastCount = recipients.length + lastCount = recipientsWithCounts.length } return {