Skip to content

Commit 3166575

Browse files
Recipient query and model (#72)
Co-authored-by: me <me@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent f09a1cd commit 3166575

12 files changed

Lines changed: 289 additions & 117 deletions

File tree

app/routes/_app+/recipients+/__editor.server.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { data as json, redirect, type ActionFunctionArgs } from 'react-router'
44
import { requireUserId } from '#app/utils/auth.server.ts'
55
import { getScheduleWindow } from '#app/utils/cron.server.ts'
66
import { prisma } from '#app/utils/db.server.ts'
7+
import {
8+
NEXT_SCHEDULE_SENTINEL_DATE,
9+
PREV_SCHEDULE_SENTINEL_DATE,
10+
} from '#app/utils/schedule-constants.server.ts'
711
import { sendText } from '#app/utils/text.server.js'
812
import { redirectWithToast } from '#app/utils/toast.server.js'
913
import {
@@ -154,23 +158,21 @@ export async function usertRecipientAction({
154158
disabled,
155159
} = submission.value
156160

157-
let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } | null =
158-
null
161+
let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date }
159162
try {
160163
scheduleData = getScheduleWindow(scheduleCron, timeZone)
161164
} catch {
162-
scheduleData = null
165+
// Use sentinel dates when schedule can't be computed
166+
scheduleData = {
167+
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
168+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
169+
}
163170
}
164171

165-
const scheduleFields = scheduleData
166-
? {
167-
prevScheduledAt: scheduleData.prevScheduledAt,
168-
nextScheduledAt: scheduleData.nextScheduledAt,
169-
}
170-
: {
171-
prevScheduledAt: null,
172-
nextScheduledAt: null,
173-
}
172+
const scheduleFields = {
173+
prevScheduledAt: scheduleData.prevScheduledAt,
174+
nextScheduledAt: scheduleData.nextScheduledAt,
175+
}
174176

175177
const user = await prisma.user.findUnique({
176178
where: { id: userId },

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ 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 {
12+
NEXT_SCHEDULE_SENTINEL_DATE,
13+
PREV_SCHEDULE_SENTINEL_DATE,
14+
} from '#app/utils/schedule-constants.server.ts'
1115
import { getCustomerProducts } from '#app/utils/stripe.server.ts'
1216

1317
function formatScheduleDisplay(date: Date, timeZone: string) {
@@ -59,8 +63,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
5963
.map((recipient) => {
6064
let nextScheduledAt = recipient.nextScheduledAt
6165
let prevScheduledAt = recipient.prevScheduledAt
66+
// Check if current value is the sentinel date (invalid schedule)
67+
const isSentinel =
68+
nextScheduledAt?.getTime() === NEXT_SCHEDULE_SENTINEL_DATE.getTime()
6269
try {
63-
if (!nextScheduledAt || !prevScheduledAt || nextScheduledAt <= now) {
70+
if (
71+
!nextScheduledAt ||
72+
!prevScheduledAt ||
73+
nextScheduledAt <= now ||
74+
isSentinel
75+
) {
6476
const scheduleWindow = getScheduleWindow(
6577
recipient.scheduleCron,
6678
recipient.timeZone,
@@ -88,10 +100,25 @@ export async function loader({ request }: LoaderFunctionArgs) {
88100
cronError: null as string | null,
89101
}
90102
} catch (error) {
103+
// Use sentinel dates for invalid schedules, update if needed
104+
const needsSentinelUpdate =
105+
!recipient.nextScheduledAt ||
106+
!recipient.prevScheduledAt ||
107+
recipient.nextScheduledAt.getTime() !==
108+
NEXT_SCHEDULE_SENTINEL_DATE.getTime() ||
109+
recipient.prevScheduledAt.getTime() !==
110+
PREV_SCHEDULE_SENTINEL_DATE.getTime()
111+
if (needsSentinelUpdate) {
112+
scheduleUpdates.push({
113+
id: recipient.id,
114+
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
115+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
116+
})
117+
}
91118
return {
92119
...recipient,
93-
prevScheduledAt,
94-
nextScheduledAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // Far future date for sorting
120+
prevScheduledAt: prevScheduledAt ?? PREV_SCHEDULE_SENTINEL_DATE,
121+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, // Sentinel date for sorting
95122
cronError:
96123
error instanceof CronParseError ? error.message : 'Invalid cron',
97124
}

app/utils/cron.server.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { faker } from '@faker-js/faker'
22
import { test, expect } from 'vitest'
33
import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts'
4-
import { sendNextTexts } from './cron.server.ts'
4+
import { getScheduleWindow, sendNextTexts } from './cron.server.ts'
55
import { prisma } from './db.server.ts'
66

77
test('does not send any texts if there are none to be sent', async () => {
@@ -18,6 +18,7 @@ test('does not send to unverified recipients', async () => {
1818
await prisma.user.create({
1919
data: {
2020
...createUser(),
21+
stripeId: faker.string.uuid(),
2122
recipients: {
2223
create: [
2324
{
@@ -43,6 +44,12 @@ test('sends a text if one is due', async () => {
4344
await prisma.sourceNumber.create({
4445
data: { phoneNumber: faker.phone.number() },
4546
})
47+
48+
// Compute schedule based on the actual cron being used
49+
const scheduleCron = '*/1 * * * *'
50+
const timeZone = 'America/Denver'
51+
const scheduleData = getScheduleWindow(scheduleCron, timeZone)
52+
4653
await prisma.user.create({
4754
data: {
4855
...createUser(),
@@ -52,7 +59,10 @@ test('sends a text if one is due', async () => {
5259
{
5360
...createRecipient(),
5461
verified: true,
55-
scheduleCron: '*/1 * * * *',
62+
scheduleCron,
63+
timeZone,
64+
prevScheduledAt: scheduleData.prevScheduledAt,
65+
nextScheduledAt: scheduleData.nextScheduledAt,
5666
messages: {
5767
create: {
5868
...createMessage(),
@@ -78,17 +88,34 @@ test(`does not send a text if it is too overdue`, async () => {
7888
await prisma.sourceNumber.create({
7989
data: { phoneNumber: faker.phone.number() },
8090
})
91+
92+
// Use a cron that's monthly, but force an overdue window within the reminder cutoff
93+
const scheduleCron = '* * 1 * *'
94+
const timeZone = 'America/Denver'
95+
const now = new Date()
96+
const prevScheduledAt = new Date(now.getTime() - 1000 * 60 * 60)
97+
const nextScheduledAt = new Date(now.getTime() + 1000 * 60 * 5)
98+
8199
await prisma.user.create({
82100
data: {
83101
...createUser(),
102+
stripeId: faker.string.uuid(),
84103
recipients: {
85104
create: [
86105
{
87106
...createRecipient(),
88107
verified: true,
89-
scheduleCron: '* * 1 * *',
108+
scheduleCron,
109+
timeZone,
110+
prevScheduledAt,
111+
nextScheduledAt,
90112
messages: {
91-
create: { ...createMessage(), sentAt: null },
113+
create: {
114+
...createMessage(),
115+
createdAt: new Date(prevScheduledAt.getTime() - 1000 * 60 * 2),
116+
updatedAt: new Date(prevScheduledAt.getTime() - 1000 * 60),
117+
sentAt: null,
118+
},
92119
},
93120
},
94121
],

app/utils/cron.server.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
setIntervalAsync,
1111
} from 'set-interval-async/dynamic'
1212
import { prisma } from './db.server.ts'
13+
import { getrecipientsforcron } from './prisma-generated.server/sql.ts'
14+
import {
15+
NEXT_SCHEDULE_SENTINEL_DATE,
16+
PREV_SCHEDULE_SENTINEL_DATE,
17+
} from './schedule-constants.server.ts'
1318
import { sendText, sendTextToRecipient } from './text.server.ts'
1419

1520
export class CronParseError extends Error {
@@ -68,47 +73,40 @@ export async function sendNextTexts() {
6873
const reminderWindowMs = 1000 * 60 * 30
6974
const reminderCutoff = new Date(now.getTime() + reminderWindowMs)
7075

71-
const recipients = await prisma.recipient.findMany({
72-
where: {
73-
verified: true,
74-
disabled: false,
75-
user: { stripeId: { not: null } },
76-
OR: [
77-
{ nextScheduledAt: { lte: reminderCutoff } },
78-
{ nextScheduledAt: null },
79-
],
80-
},
81-
select: {
82-
id: true,
83-
name: true,
84-
scheduleCron: true,
85-
timeZone: true,
86-
prevScheduledAt: true,
87-
nextScheduledAt: true,
88-
lastRemindedAt: true,
89-
lastSentAt: true,
90-
user: {
91-
select: {
92-
phoneNumber: true,
93-
name: true,
94-
},
95-
},
76+
// Optimized TypedSQL query that:
77+
// 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary)
78+
// 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId)
79+
// 3. Handles both regular scheduled recipients and those with sentinel dates
80+
// Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff,
81+
// so recipients with invalid schedules won't be included
82+
const recipients = await prisma.$queryRawTyped(
83+
getrecipientsforcron(reminderCutoff),
84+
)
85+
86+
// Transform the raw result to match the expected format
87+
const formattedRecipients = recipients.map((r) => ({
88+
...r,
89+
user: {
90+
phoneNumber: r.userPhoneNumber,
91+
name: r.userName,
9692
},
97-
})
93+
}))
9894

99-
if (!recipients.length) return
95+
if (!formattedRecipients.length) return
10096

10197
let dueSentCount = 0
10298
let reminderSentCount = 0
103-
for (const recipient of recipients) {
99+
for (const recipient of formattedRecipients) {
104100
let scheduleWindow: {
105101
prevScheduledAt: Date
106102
nextScheduledAt: Date
107-
} | null = null
103+
}
108104
if (
109105
recipient.prevScheduledAt &&
110106
recipient.nextScheduledAt &&
111-
recipient.nextScheduledAt > now
107+
recipient.nextScheduledAt > now &&
108+
recipient.nextScheduledAt.getTime() !==
109+
NEXT_SCHEDULE_SENTINEL_DATE.getTime()
112110
) {
113111
scheduleWindow = {
114112
prevScheduledAt: recipient.prevScheduledAt,
@@ -126,6 +124,14 @@ export async function sendNextTexts() {
126124
`Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`,
127125
error instanceof Error ? error.message : error,
128126
)
127+
// Update with sentinel dates to prevent repeated processing
128+
await prisma.recipient.update({
129+
where: { id: recipient.id },
130+
data: {
131+
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
132+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
133+
},
134+
})
129135
continue
130136
}
131137
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* SCHEDULE SENTINEL DATES
3+
*
4+
* These sentinel values replace NULL for schedule fields, enabling efficient SQLite index usage.
5+
*
6+
* WHY NOT NULL?
7+
* The original cron query used `OR nextScheduledAt IS NULL` which defeats SQLite index usage,
8+
* causing full table scans (~259ms). By using sentinel dates instead of NULL:
9+
* - The query becomes a simple range check: `nextScheduledAt <= $cutoff`
10+
* - SQLite can use the composite index efficiently (single-digit ms)
11+
*
12+
* See: prisma/sql/getrecipientsforcron.sql for full performance documentation.
13+
*/
14+
15+
/**
16+
* Sentinel date used when nextScheduledAt cannot be computed (invalid cron, etc.)
17+
* Using a far-future date (9999-12-31) means these recipients are naturally filtered out
18+
* by the `nextScheduledAt <= reminderCutoff` condition - no special handling needed.
19+
*/
20+
export const NEXT_SCHEDULE_SENTINEL_DATE = new Date('9999-12-31T23:59:59.999Z')
21+
22+
/**
23+
* Sentinel date used when prevScheduledAt cannot be computed (invalid cron, etc.)
24+
* Using a far-past date (1970-01-01) indicates there was never a valid previous schedule.
25+
*/
26+
export const PREV_SCHEDULE_SENTINEL_DATE = new Date('1970-01-01T00:00:00.000Z')
27+
28+
/**
29+
* @deprecated Use NEXT_SCHEDULE_SENTINEL_DATE instead
30+
*/
31+
export const SCHEDULE_SENTINEL_DATE = NEXT_SCHEDULE_SENTINEL_DATE

app/utils/text.server.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { z } from 'zod'
22
import { getScheduleWindow } from './cron.server.ts'
33
import { prisma } from './db.server.ts'
4+
import {
5+
NEXT_SCHEDULE_SENTINEL_DATE,
6+
PREV_SCHEDULE_SENTINEL_DATE,
7+
} from './schedule-constants.server.ts'
48
import { getCustomerProducts } from './stripe.server.ts'
59

610
const { TWILIO_SID, TWILIO_TOKEN } = process.env
@@ -96,33 +100,35 @@ export async function sendTextToRecipient({
96100
})
97101
if (result.status === 'success') {
98102
const sentAt = new Date()
99-
let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date } | null =
100-
null
103+
let scheduleData: { prevScheduledAt: Date; nextScheduledAt: Date }
101104
try {
102105
scheduleData = getScheduleWindow(
103106
recipient.scheduleCron,
104107
recipient.timeZone,
105108
sentAt,
106109
)
107110
} catch {
108-
scheduleData = null
111+
// Use sentinel dates when schedule can't be computed
112+
scheduleData = {
113+
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
114+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
115+
}
109116
}
110117

111118
await prisma.message.update({
112119
select: { id: true },
113120
where: { id: messageId },
114121
data: { sentAt, twilioId: result.data.sid },
115122
})
123+
// Update denormalized fields on Recipient for cron query performance.
124+
// lastSentAt is equivalent to MAX(Message.sentAt) but stored directly to avoid
125+
// slow JOIN + GROUP BY aggregation. See prisma/sql/getrecipientsforcron.sql
116126
await prisma.recipient.update({
117127
where: { id: recipientId },
118128
data: {
119129
lastSentAt: sentAt,
120-
...(scheduleData
121-
? {
122-
prevScheduledAt: scheduleData.prevScheduledAt,
123-
nextScheduledAt: scheduleData.nextScheduledAt,
124-
}
125-
: {}),
130+
prevScheduledAt: scheduleData.prevScheduledAt,
131+
nextScheduledAt: scheduleData.nextScheduledAt,
126132
},
127133
})
128134
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Optimize the cron query by adding a composite index that matches the query pattern
2+
-- This index covers: verified, disabled, nextScheduledAt (for range queries), and userId (for the join)
3+
4+
-- Drop existing less optimal indexes that will be superseded
5+
DROP INDEX IF EXISTS "Recipient_verified_disabled_idx";
6+
DROP INDEX IF EXISTS "Recipient_nextScheduledAt_idx";
7+
DROP INDEX IF EXISTS "Recipient_userId_nextScheduledAt_idx";
8+
9+
-- Update NULL schedule values to sentinel dates
10+
-- nextScheduledAt uses far-future date (9999-12-31) - will be filtered out by the query
11+
-- prevScheduledAt uses far-past date (1970-01-01) - indicates no previous schedule
12+
-- This eliminates the OR ... IS NULL pattern which defeats index usage in SQLite
13+
UPDATE "Recipient" SET "nextScheduledAt" = '9999-12-31T23:59:59.999Z' WHERE "nextScheduledAt" IS NULL;
14+
UPDATE "Recipient" SET "prevScheduledAt" = '1970-01-01T00:00:00.000Z' WHERE "prevScheduledAt" IS NULL;
15+
16+
-- Create optimized composite index for the cron query
17+
-- Order: equality columns first (verified, disabled), then range column (nextScheduledAt), then join column (userId)
18+
CREATE INDEX "Recipient_cron_query_idx" ON "Recipient"("verified", "disabled", "nextScheduledAt", "userId");
19+
20+
-- Create a partial index on User for stripeId IS NOT NULL - this helps the EXISTS subquery
21+
CREATE INDEX "User_stripe_active_idx" ON "User"("id") WHERE "stripeId" IS NOT NULL;

0 commit comments

Comments
 (0)