Skip to content

Commit 5b1bda8

Browse files
Recipient unsent messages (#73)
Co-authored-by: me <me@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e821fec commit 5b1bda8

7 files changed

Lines changed: 68 additions & 8 deletions

File tree

.cursor/rules/agents.mdc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Agent instructions
2+
3+
- Use typed SQL (generated `sql` helpers with `prisma.$queryRawTyped`).
4+
- Do not use `prisma.$queryRaw` or `Prisma.sql`.
5+
- Exception: Prisma typed SQL filenames must be valid JS identifiers (no dashes), so the lower-kebab-case rule does not apply under `prisma/sql/`.

app/routes/_app+/recipients+/_layout.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
88
import { requireUserId } from '#app/utils/auth.server.js'
99
import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts'
1010
import { prisma } from '#app/utils/db.server.ts'
11+
import { getunsentmessagecounts } from '#app/utils/prisma-generated.server/sql.ts'
1112
import {
1213
NEXT_SCHEDULE_SENTINEL_DATE,
1314
PREV_SCHEDULE_SENTINEL_DATE,
@@ -36,6 +37,7 @@ function formatScheduleDisplay(date: Date, timeZone: string) {
3637

3738
export async function loader({ request }: LoaderFunctionArgs) {
3839
const userId = await requireUserId(request)
40+
3941
const recipients = await prisma.recipient.findMany({
4042
select: {
4143
id: true,
@@ -46,10 +48,25 @@ export async function loader({ request }: LoaderFunctionArgs) {
4648
disabled: true,
4749
prevScheduledAt: true,
4850
nextScheduledAt: true,
49-
_count: { select: { messages: { where: { sentAt: null } } } },
5051
},
51-
where: { userId },
52+
where: {
53+
userId,
54+
},
55+
orderBy: { id: 'asc' },
5256
})
57+
const recipientIds = recipients.map((recipient) => recipient.id)
58+
const messageCounts = recipientIds.length
59+
? await prisma.$queryRawTyped(
60+
getunsentmessagecounts(JSON.stringify(recipientIds)),
61+
)
62+
: []
63+
const messageCountByRecipientId = new Map(
64+
messageCounts.map((row) => [row.recipientId, Number(row.unsentCount ?? 0)]),
65+
)
66+
const recipientsWithCounts = recipients.map((recipient) => ({
67+
...recipient,
68+
messageCount: messageCountByRecipientId.get(recipient.id) ?? 0,
69+
}))
5370

5471
const now = new Date()
5572
const scheduleUpdates: Array<{
@@ -59,7 +76,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
5976
}> = []
6077

6178
// Ensure we have a schedule window for sorting/display
62-
const sortedRecipients = recipients
79+
const sortedRecipients = recipientsWithCounts
6380
.map((recipient) => {
6481
let nextScheduledAt = recipient.nextScheduledAt
6582
let prevScheduledAt = recipient.prevScheduledAt

app/routes/_app+/recipients+/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default function RecipientsIndexRoute() {
6767
{hasRecipients ? (
6868
<div className="md:divide-border space-y-4 md:space-y-0 md:divide-y">
6969
{recipients.map((recipient) => {
70-
const messageCount = recipient._count.messages
70+
const messageCount = recipient.messageCount
7171
const messageLabel = messageCount === 1 ? 'message' : 'messages'
7272
const messageText = `${messageCount} ${messageLabel}`
7373
const messagePreparedText = `${messageText} prepared`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- CreateIndex
2+
CREATE INDEX "recipient_by_user" ON "Recipient"("userId", "id");
3+
4+
-- CreateIndex
5+
CREATE INDEX "message_unsent_by_recipient" ON "Message"("recipientId")
6+
WHERE "sentAt" IS NULL;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ model Recipient {
147147
148148
// non-unique foreign key
149149
@@index([userId])
150+
@@index([userId, id], name: "recipient_by_user")
150151
// Optimized composite index for cron query: equality cols first, then range, then join
151152
@@index([verified, disabled, nextScheduledAt, userId], name: "Recipient_cron_query_idx")
152153
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Count unsent messages for a list of recipient IDs.
2+
--
3+
-- @param {String} $1 - JSON array of recipient ids
4+
SELECT
5+
recipientId,
6+
CAST(COUNT(*) AS INTEGER) AS unsentCount
7+
FROM Message INDEXED BY message_unsent_by_recipient
8+
WHERE sentAt IS NULL
9+
AND recipientId IN (SELECT value FROM json_each($1))
10+
GROUP BY recipientId;

scripts/benchmark-performance.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import { performance } from 'node:perf_hooks'
33
import { parseArgs } from 'node:util'
44
import { CronParseError, getScheduleWindow } from '#app/utils/cron.server.ts'
55
import { prisma } from '#app/utils/db.server.ts'
6-
import { getrecipientsforcron } from '#app/utils/prisma-generated.server/sql.ts'
6+
import {
7+
getrecipientsforcron,
8+
getunsentmessagecounts,
9+
} from '#app/utils/prisma-generated.server/sql.ts'
710
import { NEXT_SCHEDULE_SENTINEL_DATE } from '#app/utils/schedule-constants.server.ts'
811

912
const MESSAGES_PER_PAGE = 100
13+
const RECIPIENTS_PAGE_SIZE = 200
1014

1115
type Summary = {
1216
min: number
@@ -120,14 +124,31 @@ async function benchmarkRecipientsList(
120124
disabled: true,
121125
prevScheduledAt: true,
122126
nextScheduledAt: true,
123-
_count: { select: { messages: { where: { sentAt: null } } } },
124127
},
128+
orderBy: { id: 'asc' },
129+
take: RECIPIENTS_PAGE_SIZE,
125130
})
131+
const recipientIds = recipients.map((recipient) => recipient.id)
132+
const messageCounts = recipientIds.length
133+
? await prisma.$queryRawTyped(
134+
getunsentmessagecounts(JSON.stringify(recipientIds)),
135+
)
136+
: []
137+
const messageCountByRecipientId = new Map(
138+
messageCounts.map((row) => [
139+
row.recipientId,
140+
Number(row.unsentCount ?? 0),
141+
]),
142+
)
143+
const recipientsWithCounts = recipients.map((recipient) => ({
144+
...recipient,
145+
messageCount: messageCountByRecipientId.get(recipient.id) ?? 0,
146+
}))
126147
const queryMs = performance.now() - queryStart
127148

128149
const computeStart = performance.now()
129150
let errors = 0
130-
const sortedRecipients = recipients
151+
const sortedRecipients = recipientsWithCounts
131152
.map((recipient) => {
132153
try {
133154
const isSentinel =
@@ -184,7 +205,7 @@ async function benchmarkRecipientsList(
184205
querySamples.push(queryMs)
185206
computeSamples.push(computeMs)
186207
cronErrors.push(errors)
187-
lastCount = recipients.length
208+
lastCount = recipientsWithCounts.length
188209
}
189210

190211
return {

0 commit comments

Comments
 (0)