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 {