From 2c39d15158aa174bfba3dc7bf1e3e3465ce2396b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 18:35:31 +0000 Subject: [PATCH 01/11] Split recipient message count queries Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 46 +++++++++++++++++-- app/routes/_app+/recipients+/index.tsx | 2 +- .../migration.sql | 6 +++ prisma/schema.prisma | 1 + scripts/benchmark-performance.ts | 31 +++++++++++-- 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260202160000-recipient-message-indexes/migration.sql diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index e8003a54..8bb37f5d 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -4,6 +4,7 @@ import { Outlet, useLoaderData, } from 'react-router' +import { Prisma } from '@prisma/client' 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' @@ -34,8 +35,21 @@ function formatScheduleDisplay(date: Date, timeZone: string) { return `Every ${weekday} at ${hour}:${minute} ${dayPeriod} ${timeZoneName}`.trim() } +const DEFAULT_RECIPIENTS_PAGE_SIZE = 200 +const MAX_RECIPIENTS_PAGE_SIZE = 1000 + +function parsePageSize(value: string | null) { + const parsed = Number(value ?? DEFAULT_RECIPIENTS_PAGE_SIZE) + if (!Number.isFinite(parsed)) return DEFAULT_RECIPIENTS_PAGE_SIZE + return Math.min(MAX_RECIPIENTS_PAGE_SIZE, Math.max(1, Math.floor(parsed))) +} + export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request) + const url = new URL(request.url) + const pageSize = parsePageSize(url.searchParams.get('limit')) + const cursor = url.searchParams.get('cursor') + const recipients = await prisma.recipient.findMany({ select: { id: true, @@ -46,10 +60,36 @@ export async function loader({ request }: LoaderFunctionArgs) { disabled: true, prevScheduledAt: true, nextScheduledAt: true, - _count: { select: { messages: { where: { sentAt: null } } } }, }, - where: { userId }, + where: { + userId, + ...(cursor ? { id: { gt: cursor } } : {}), + }, + orderBy: { id: 'asc' }, + take: pageSize, }) + const recipientIds = recipients.map((recipient) => recipient.id) + const messageCounts = recipientIds.length + ? await prisma.$queryRaw< + Array<{ recipientId: string; unsentCount: number | bigint }> + >(Prisma.sql` + SELECT "recipientId", COUNT(*) AS "unsentCount" + FROM "Message" + WHERE "sentAt" IS NULL + AND "recipientId" IN (${Prisma.join(recipientIds)}) + GROUP BY "recipientId" + `) + : [] + const messageCountByRecipientId = new Map( + messageCounts.map((row) => [ + row.recipientId, + Number(row.unsentCount), + ]), + ) + const recipientsWithCounts = recipients.map((recipient) => ({ + ...recipient, + messageCount: messageCountByRecipientId.get(recipient.id) ?? 0, + })) const now = new Date() const scheduleUpdates: Array<{ @@ -59,7 +99,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/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index 1ad5949c..ec87d949 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -1,12 +1,14 @@ import 'dotenv/config' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' +import { Prisma } from '@prisma/client' 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 { 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 +122,37 @@ 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.$queryRaw< + Array<{ recipientId: string; unsentCount: number | bigint }> + >(Prisma.sql` + SELECT "recipientId", COUNT(*) AS "unsentCount" + FROM "Message" + WHERE "sentAt" IS NULL + AND "recipientId" IN (${Prisma.join(recipientIds)}) + GROUP BY "recipientId" + `) + : [] + const messageCountByRecipientId = new Map( + messageCounts.map((row) => [ + row.recipientId, + Number(row.unsentCount), + ]), + ) + 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 +209,7 @@ async function benchmarkRecipientsList( querySamples.push(queryMs) computeSamples.push(computeMs) cronErrors.push(errors) - lastCount = recipients.length + lastCount = recipientsWithCounts.length } return { From 47aaa4f545d70a3d727cbb5f47d867880f926a9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 18:37:03 +0000 Subject: [PATCH 02/11] Force partial index for unsent counts Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 2 +- scripts/benchmark-performance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 8bb37f5d..ae5332d3 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -74,7 +74,7 @@ export async function loader({ request }: LoaderFunctionArgs) { Array<{ recipientId: string; unsentCount: number | bigint }> >(Prisma.sql` SELECT "recipientId", COUNT(*) AS "unsentCount" - FROM "Message" + FROM "Message" INDEXED BY "message_unsent_by_recipient" WHERE "sentAt" IS NULL AND "recipientId" IN (${Prisma.join(recipientIds)}) GROUP BY "recipientId" diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index ec87d949..1468fee2 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -132,7 +132,7 @@ async function benchmarkRecipientsList( Array<{ recipientId: string; unsentCount: number | bigint }> >(Prisma.sql` SELECT "recipientId", COUNT(*) AS "unsentCount" - FROM "Message" + FROM "Message" INDEXED BY "message_unsent_by_recipient" WHERE "sentAt" IS NULL AND "recipientId" IN (${Prisma.join(recipientIds)}) GROUP BY "recipientId" From ae08f7a39fef24aa857649ee541d08bf52ab4801 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 18:39:21 +0000 Subject: [PATCH 03/11] Use generated Prisma SQL helpers Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 2 +- scripts/benchmark-performance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index ae5332d3..8e0f40ee 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -4,7 +4,7 @@ import { Outlet, useLoaderData, } from 'react-router' -import { Prisma } from '@prisma/client' +import { Prisma } from '#app/utils/prisma-generated.server/client.ts' 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' diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index 1468fee2..9df6c792 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -1,7 +1,7 @@ import 'dotenv/config' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' -import { Prisma } from '@prisma/client' +import { Prisma } from '#app/utils/prisma-generated.server/client.ts' 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' From e0a3f97744f8357acdbb43a25f8f7c6983872f25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 18:41:42 +0000 Subject: [PATCH 04/11] Fix import order for Prisma helpers Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 2 +- scripts/benchmark-performance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 8e0f40ee..8edcee56 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -4,11 +4,11 @@ import { Outlet, useLoaderData, } from 'react-router' -import { Prisma } from '#app/utils/prisma-generated.server/client.ts' 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 { Prisma } from '#app/utils/prisma-generated.server/client.ts' import { NEXT_SCHEDULE_SENTINEL_DATE, PREV_SCHEDULE_SENTINEL_DATE, diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index 9df6c792..b1736958 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -1,9 +1,9 @@ import 'dotenv/config' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' -import { Prisma } from '#app/utils/prisma-generated.server/client.ts' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' +import { Prisma } from '#app/utils/prisma-generated.server/client.ts' import { getrecipientsforcron } from '#app/utils/prisma-generated.server/sql.ts' import { NEXT_SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts' From 4db014b4406613a576f213eece516f272f3ee702 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:09:17 +0000 Subject: [PATCH 05/11] Document typed SQL preference Co-authored-by: me --- .cursor/rules/agents.mdc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .cursor/rules/agents.mdc diff --git a/.cursor/rules/agents.mdc b/.cursor/rules/agents.mdc new file mode 100644 index 00000000..dc2f1a1c --- /dev/null +++ b/.cursor/rules/agents.mdc @@ -0,0 +1,4 @@ +# Agent instructions + +- Prefer typed SQL (generated `sql` helpers with `prisma.$queryRawTyped`) over `Prisma.sql`. +- Avoid `Prisma.sql` unless typed SQL cannot express the query. From 5cb3bef9080c82b9afda0bd96d2bdb80a3ec03ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:10:19 +0000 Subject: [PATCH 06/11] Format recipients query changes Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 5 +---- scripts/benchmark-performance.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 8edcee56..b23999ec 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -81,10 +81,7 @@ export async function loader({ request }: LoaderFunctionArgs) { `) : [] const messageCountByRecipientId = new Map( - messageCounts.map((row) => [ - row.recipientId, - Number(row.unsentCount), - ]), + messageCounts.map((row) => [row.recipientId, Number(row.unsentCount)]), ) const recipientsWithCounts = recipients.map((recipient) => ({ ...recipient, diff --git a/scripts/benchmark-performance.ts b/scripts/benchmark-performance.ts index b1736958..940e1f12 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -139,10 +139,7 @@ async function benchmarkRecipientsList( `) : [] const messageCountByRecipientId = new Map( - messageCounts.map((row) => [ - row.recipientId, - Number(row.unsentCount), - ]), + messageCounts.map((row) => [row.recipientId, Number(row.unsentCount)]), ) const recipientsWithCounts = recipients.map((recipient) => ({ ...recipient, From 86223b1a59fcd6cce0d8e9b717e730c1a5562b54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:37:21 +0000 Subject: [PATCH 07/11] Use typed SQL for unsent counts Co-authored-by: me --- .cursor/rules/agents.mdc | 4 ++-- app/routes/_app+/recipients+/_layout.tsx | 19 ++++++++----------- prisma/sql/getunsentmessagecounts.sql | 10 ++++++++++ scripts/benchmark-performance.ts | 23 +++++++++++------------ 4 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 prisma/sql/getunsentmessagecounts.sql diff --git a/.cursor/rules/agents.mdc b/.cursor/rules/agents.mdc index dc2f1a1c..96876dbe 100644 --- a/.cursor/rules/agents.mdc +++ b/.cursor/rules/agents.mdc @@ -1,4 +1,4 @@ # Agent instructions -- Prefer typed SQL (generated `sql` helpers with `prisma.$queryRawTyped`) over `Prisma.sql`. -- Avoid `Prisma.sql` unless typed SQL cannot express the query. +- Use typed SQL (generated `sql` helpers with `prisma.$queryRawTyped`). +- Do not use `prisma.$queryRaw` or `Prisma.sql`. diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index b23999ec..258f6c5a 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -8,7 +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 { Prisma } from '#app/utils/prisma-generated.server/client.ts' +import { getunsentmessagecounts } from '#app/utils/prisma-generated.server/sql.ts' import { NEXT_SCHEDULE_SENTINEL_DATE, PREV_SCHEDULE_SENTINEL_DATE, @@ -70,18 +70,15 @@ export async function loader({ request }: LoaderFunctionArgs) { }) const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length - ? await prisma.$queryRaw< - Array<{ recipientId: string; unsentCount: number | bigint }> - >(Prisma.sql` - SELECT "recipientId", COUNT(*) AS "unsentCount" - FROM "Message" INDEXED BY "message_unsent_by_recipient" - WHERE "sentAt" IS NULL - AND "recipientId" IN (${Prisma.join(recipientIds)}) - GROUP BY "recipientId" - `) + ? await prisma.$queryRawTyped( + getunsentmessagecounts(JSON.stringify(recipientIds)), + ) : [] const messageCountByRecipientId = new Map( - messageCounts.map((row) => [row.recipientId, Number(row.unsentCount)]), + messageCounts.map((row) => [ + row.recipientId, + Number(row.unsentCount ?? 0), + ]), ) const recipientsWithCounts = recipients.map((recipient) => ({ ...recipient, 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 940e1f12..f993c824 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -3,8 +3,10 @@ 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 { Prisma } from '#app/utils/prisma-generated.server/client.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 @@ -128,18 +130,15 @@ async function benchmarkRecipientsList( }) const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length - ? await prisma.$queryRaw< - Array<{ recipientId: string; unsentCount: number | bigint }> - >(Prisma.sql` - SELECT "recipientId", COUNT(*) AS "unsentCount" - FROM "Message" INDEXED BY "message_unsent_by_recipient" - WHERE "sentAt" IS NULL - AND "recipientId" IN (${Prisma.join(recipientIds)}) - GROUP BY "recipientId" - `) + ? await prisma.$queryRawTyped( + getunsentmessagecounts(JSON.stringify(recipientIds)), + ) : [] const messageCountByRecipientId = new Map( - messageCounts.map((row) => [row.recipientId, Number(row.unsentCount)]), + messageCounts.map((row) => [ + row.recipientId, + Number(row.unsentCount ?? 0), + ]), ) const recipientsWithCounts = recipients.map((recipient) => ({ ...recipient, From 1cc7aa1af87352178de9a7298a63fa8efe7df7ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:38:03 +0000 Subject: [PATCH 08/11] Format recipients loader Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 258f6c5a..2f1675c0 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -75,10 +75,7 @@ export async function loader({ request }: LoaderFunctionArgs) { ) : [] const messageCountByRecipientId = new Map( - messageCounts.map((row) => [ - row.recipientId, - Number(row.unsentCount ?? 0), - ]), + messageCounts.map((row) => [row.recipientId, Number(row.unsentCount ?? 0)]), ) const recipientsWithCounts = recipients.map((recipient) => ({ ...recipient, From 6987c2f9e0a51dd0d43f7895b8e9d7359fd19c27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:53:35 +0000 Subject: [PATCH 09/11] Inline typed SQL for unsent counts Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 21 ++++++++++++++++++-- prisma/sql/getunsentmessagecounts.sql | 10 ---------- scripts/benchmark-performance.ts | 25 +++++++++++++++++++----- 3 files changed, 39 insertions(+), 17 deletions(-) delete mode 100644 prisma/sql/getunsentmessagecounts.sql diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 2f1675c0..1ab598bf 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -4,11 +4,11 @@ import { Outlet, useLoaderData, } from 'react-router' +import * as prismaRuntime from '@prisma/client/runtime/client' 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, @@ -38,6 +38,23 @@ function formatScheduleDisplay(date: Date, timeZone: string) { const DEFAULT_RECIPIENTS_PAGE_SIZE = 200 const MAX_RECIPIENTS_PAGE_SIZE = 1000 +type UnsentMessageCountRow = { + recipientId: string + unsentCount: number | null +} + +const getUnsentMessageCounts = prismaRuntime.makeTypedQueryFactory( + `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;`, +) as ( + recipientIdsJson: string, +) => prismaRuntime.TypedSql<[recipientIdsJson: string], UnsentMessageCountRow> + function parsePageSize(value: string | null) { const parsed = Number(value ?? DEFAULT_RECIPIENTS_PAGE_SIZE) if (!Number.isFinite(parsed)) return DEFAULT_RECIPIENTS_PAGE_SIZE @@ -71,7 +88,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length ? await prisma.$queryRawTyped( - getunsentmessagecounts(JSON.stringify(recipientIds)), + getUnsentMessageCounts(JSON.stringify(recipientIds)), ) : [] const messageCountByRecipientId = new Map( diff --git a/prisma/sql/getunsentmessagecounts.sql b/prisma/sql/getunsentmessagecounts.sql deleted file mode 100644 index 551ced56..00000000 --- a/prisma/sql/getunsentmessagecounts.sql +++ /dev/null @@ -1,10 +0,0 @@ --- 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 f993c824..91b1c749 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -1,17 +1,32 @@ import 'dotenv/config' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' +import * as prismaRuntime from '@prisma/client/runtime/client' import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { - getrecipientsforcron, - getunsentmessagecounts, -} from '#app/utils/prisma-generated.server/sql.ts' +import { getrecipientsforcron } 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 UnsentMessageCountRow = { + recipientId: string + unsentCount: number | null +} + +const getUnsentMessageCounts = prismaRuntime.makeTypedQueryFactory( + `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;`, +) as ( + recipientIdsJson: string, +) => prismaRuntime.TypedSql<[recipientIdsJson: string], UnsentMessageCountRow> + type Summary = { min: number max: number @@ -131,7 +146,7 @@ async function benchmarkRecipientsList( const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length ? await prisma.$queryRawTyped( - getunsentmessagecounts(JSON.stringify(recipientIds)), + getUnsentMessageCounts(JSON.stringify(recipientIds)), ) : [] const messageCountByRecipientId = new Map( From 7c779e4afdac6a9b4439975052f275abfb6b03a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 19:57:59 +0000 Subject: [PATCH 10/11] Restore typed SQL helper file Co-authored-by: me --- .cursor/rules/agents.mdc | 1 + app/routes/_app+/recipients+/_layout.tsx | 21 ++------------------ prisma/sql/getunsentmessagecounts.sql | 10 ++++++++++ scripts/benchmark-performance.ts | 25 +++++------------------- 4 files changed, 18 insertions(+), 39 deletions(-) create mode 100644 prisma/sql/getunsentmessagecounts.sql diff --git a/.cursor/rules/agents.mdc b/.cursor/rules/agents.mdc index 96876dbe..674a902a 100644 --- a/.cursor/rules/agents.mdc +++ b/.cursor/rules/agents.mdc @@ -2,3 +2,4 @@ - 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 1ab598bf..2f1675c0 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -4,11 +4,11 @@ import { Outlet, useLoaderData, } from 'react-router' -import * as prismaRuntime from '@prisma/client/runtime/client' 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, @@ -38,23 +38,6 @@ function formatScheduleDisplay(date: Date, timeZone: string) { const DEFAULT_RECIPIENTS_PAGE_SIZE = 200 const MAX_RECIPIENTS_PAGE_SIZE = 1000 -type UnsentMessageCountRow = { - recipientId: string - unsentCount: number | null -} - -const getUnsentMessageCounts = prismaRuntime.makeTypedQueryFactory( - `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;`, -) as ( - recipientIdsJson: string, -) => prismaRuntime.TypedSql<[recipientIdsJson: string], UnsentMessageCountRow> - function parsePageSize(value: string | null) { const parsed = Number(value ?? DEFAULT_RECIPIENTS_PAGE_SIZE) if (!Number.isFinite(parsed)) return DEFAULT_RECIPIENTS_PAGE_SIZE @@ -88,7 +71,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length ? await prisma.$queryRawTyped( - getUnsentMessageCounts(JSON.stringify(recipientIds)), + getunsentmessagecounts(JSON.stringify(recipientIds)), ) : [] const messageCountByRecipientId = new Map( 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 91b1c749..f993c824 100644 --- a/scripts/benchmark-performance.ts +++ b/scripts/benchmark-performance.ts @@ -1,32 +1,17 @@ import 'dotenv/config' import { performance } from 'node:perf_hooks' import { parseArgs } from 'node:util' -import * as prismaRuntime from '@prisma/client/runtime/client' 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 UnsentMessageCountRow = { - recipientId: string - unsentCount: number | null -} - -const getUnsentMessageCounts = prismaRuntime.makeTypedQueryFactory( - `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;`, -) as ( - recipientIdsJson: string, -) => prismaRuntime.TypedSql<[recipientIdsJson: string], UnsentMessageCountRow> - type Summary = { min: number max: number @@ -146,7 +131,7 @@ async function benchmarkRecipientsList( const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length ? await prisma.$queryRawTyped( - getUnsentMessageCounts(JSON.stringify(recipientIds)), + getunsentmessagecounts(JSON.stringify(recipientIds)), ) : [] const messageCountByRecipientId = new Map( From f228e8843c2eab78ec370f002a55a5d42f767a1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 20:04:22 +0000 Subject: [PATCH 11/11] Fix recipients list truncation Co-authored-by: me --- app/routes/_app+/recipients+/_layout.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/routes/_app+/recipients+/_layout.tsx b/app/routes/_app+/recipients+/_layout.tsx index 2f1675c0..5919eafb 100644 --- a/app/routes/_app+/recipients+/_layout.tsx +++ b/app/routes/_app+/recipients+/_layout.tsx @@ -35,20 +35,8 @@ function formatScheduleDisplay(date: Date, timeZone: string) { return `Every ${weekday} at ${hour}:${minute} ${dayPeriod} ${timeZoneName}`.trim() } -const DEFAULT_RECIPIENTS_PAGE_SIZE = 200 -const MAX_RECIPIENTS_PAGE_SIZE = 1000 - -function parsePageSize(value: string | null) { - const parsed = Number(value ?? DEFAULT_RECIPIENTS_PAGE_SIZE) - if (!Number.isFinite(parsed)) return DEFAULT_RECIPIENTS_PAGE_SIZE - return Math.min(MAX_RECIPIENTS_PAGE_SIZE, Math.max(1, Math.floor(parsed))) -} - export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request) - const url = new URL(request.url) - const pageSize = parsePageSize(url.searchParams.get('limit')) - const cursor = url.searchParams.get('cursor') const recipients = await prisma.recipient.findMany({ select: { @@ -63,10 +51,8 @@ export async function loader({ request }: LoaderFunctionArgs) { }, where: { userId, - ...(cursor ? { id: { gt: cursor } } : {}), }, orderBy: { id: 'asc' }, - take: pageSize, }) const recipientIds = recipients.map((recipient) => recipient.id) const messageCounts = recipientIds.length