Route read-only raw Prisma queries to read replica#1467
Conversation
- Update all pure-read $queryRaw/$queryRawUnsafe calls to use $replica() - Add AGENTS.md note about when to use read replicas for raw queries - Skip queries inside transactions, queries with writes in CTEs, and lookups that feed directly into create/upsert logic Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR routes all read-only Prisma database queries through ChangesReplica Read Routing Implementation
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…nous) Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
Now that we know writes await replication, there's no read-after-write concern. Update remaining SELECT queries in CLI auth routes, getManagedEmailDomainByTenancyAndSubdomain, and local emulator project lookup. Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
Greptile SummaryThis PR routes all read-only raw Prisma queries (
Confidence Score: 3/5The bulk of the changes are safe, but the local emulator endpoint has two reads incorrectly moved to the replica that bracket primary writes — one can silently overwrite the project mapping and the other will throw an assertion error on first use. The overwhelming majority of queries are straightforward read-only SELECTs with no writes depending on them. The local emulator route is the exception: getOrCreateLocalEmulatorProjectId reads from the replica to decide whether to create a new project ID then writes unconditionally to the primary, causing silent project mapping corruption; syncLocalEmulatorOnboardingStatus then reads the just-written Project row from the replica before it replicates, throwing an uncaught StackAssertionError on every first-time creation. apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx — both getOrCreateLocalEmulatorProjectId and syncLocalEmulatorOnboardingStatus need their $replica() reads reverted to primary reads. Important Files Changed
|
Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR routes read-only raw Prisma queries ($queryRaw / $queryRawUnsafe) through $replica() to reduce load on the primary database, and documents the intended usage in AGENTS.md.
Changes:
- Updated multiple backend raw SQL reads to call
prisma.$replica().$queryRaw(...). - Applied the same change across several internal API routes and backend libs.
- Added an
AGENTS.mdguideline describing when to use$replica()for raw queries.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| apps/backend/src/lib/managed-email-domains.tsx | Routes managed-email domain raw SELECTs through $replica(). |
| apps/backend/src/lib/development-environment.ts | Routes the development-environment flag lookup through $replica(). |
| apps/backend/src/lib/conversations.tsx | Routes conversation read queries through $replica(). |
| apps/backend/src/app/api/latest/internal/session-replays/session-replay-admin-rows.ts | Routes session replay admin list raw query through $replica(). |
| apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx | Routes internal ledger transaction raw queries through $replica(). |
| apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx | Routes several local-emulator read queries through $replica(). |
| apps/backend/src/app/api/latest/internal/external-db-sync/status/route.ts | Routes internal stats/count raw queries through $replica(). |
| apps/backend/src/app/api/latest/auth/cli/route.tsx | Routes refresh-token validation read through $replica(). |
| apps/backend/src/app/api/latest/auth/cli/poll/route.tsx | Routes CLI auth attempt polling read through $replica(). |
| apps/backend/src/app/api/latest/auth/cli/complete/route.tsx | Routes CLI auth attempt + refresh-token reads through $replica(). |
| AGENTS.md | Documents $replica() usage guidance for raw queries. |
Comments suppressed due to low confidence (9)
apps/backend/src/app/api/latest/auth/cli/poll/route.tsx:61
- Using the read replica here can break the CLI auth flow due to replica lag: the POST route inserts into CliAuthAttempt on the primary and the client may poll immediately; a replica read may not see the row yet and incorrectly throw InvalidPollingCodeError. This query should stay on the primary (or otherwise guarantee read-your-writes consistency for newly created attempts).
const cliAuthRows = await prisma.$replica().$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
SELECT
"id",
"refreshToken",
"expiresAt",
"usedAt"
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "pollingCode" = ${polling_code}
LIMIT 1
`);
apps/backend/src/app/api/latest/auth/cli/complete/route.tsx:118
- Reading pending CliAuthAttempt from the replica risks false "Invalid login code" failures right after the attempt is created (replica lag). Since this endpoint is part of the immediate CLI login flow, this lookup should use the primary connection to ensure read-your-writes behavior.
async function getPendingCliAuthAttempt(tenancy: Tenancy, loginCode: string) {
// CliAuthAttempt lives in the tenancy's source-of-truth DB, consistent with cli/poll/route.tsx.
const prisma = await getPrismaClientForTenancy(tenancy);
const schema = await getPrismaSchemaForTenancy(tenancy);
const rows = await prisma.$replica().$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
SELECT
"id",
"tenancyId",
"refreshToken",
"anonRefreshToken",
"expiresAt",
"usedAt"
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "loginCode" = ${loginCode}
LIMIT 1
`);
apps/backend/src/app/api/latest/auth/cli/route.tsx:50
- This refresh-token lookup is used for authentication/authorization decisions. Routing it to the replica can allow recently revoked/deleted refresh tokens to be accepted until replication catches up. Consider keeping this read on the primary (or otherwise ensuring strongly consistent reads for session/refresh-token validation).
if (anon_refresh_token != null) {
const refreshTokenRows = await globalPrismaClient.$replica().$queryRaw<RefreshTokenRow[]>(Prisma.sql`
SELECT "tenancyId", "projectUserId", "expiresAt"
FROM "ProjectUserRefreshToken"
WHERE "refreshToken" = ${anon_refresh_token}
LIMIT 1
`);
apps/backend/src/app/api/latest/auth/cli/complete/route.tsx:143
- This refresh-token lookup participates in session validation. If the refresh token row is revoked/deleted on the primary, a replica read may still return it briefly, causing incorrect acceptance. Consider keeping this query on the primary DB for strong consistency in auth flows.
async function getRefreshTokenSession(tenancyId: string, refreshToken: string) {
// ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx).
const rows = await globalPrismaClient.$replica().$queryRaw<RefreshTokenRow[]>(Prisma.sql`
SELECT
"id",
"tenancyId",
"projectUserId",
"refreshToken",
"expiresAt"
FROM "ProjectUserRefreshToken"
WHERE "refreshToken" = ${refreshToken}
LIMIT 1
`);
apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx:93
- This existence check influences subsequent writes (project/tenancy upserts and the LocalEmulatorProject mapping upsert). If the replica is behind, this can incorrectly treat an existing mapping as missing and create a new projectId, potentially overwriting the mapping and leaving orphaned Project/Tenancy rows. This read should stay on the primary (or run inside a transaction on the primary) to avoid stale-read write skew.
async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise<{ projectId: string, created: boolean }> {
const existingRows = await globalPrismaClient.$replica().$queryRaw<LocalEmulatorProjectMappingRow[]>(Prisma.sql`
SELECT "projectId"
FROM "LocalEmulatorProject"
WHERE "absoluteFilePath" = ${absoluteFilePath}
LIMIT 1
`);
const existingRow = existingRows.length > 0 ? existingRows[0] : undefined;
const projectId = existingRow ? existingRow.projectId : generateUuid();
apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx:206
- These reads are used to drive write decisions later in the function (e.g., whether to update onboardingStatus / onboardingState). Reading from the replica can apply the wrong transition when the primary has a newer onboardingStatus value (replica lag), leading to inconsistent state. For correctness, use the primary for the onboardingStatus read (and consider whether the information_schema check needs to be strongly consistent as well).
async function syncLocalEmulatorOnboardingStatus(projectId: string, showOnboarding: boolean): Promise<ProjectOnboardingStatus> {
const onboardingStateColumnExistsRows = await globalPrismaClient.$replica().$queryRaw<Array<{ exists: boolean }>>(Prisma.sql`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Project'
AND column_name = 'onboardingState'
) AS "exists"
`);
const onboardingStateColumnExists = onboardingStateColumnExistsRows[0]?.exists === true;
const rows = await globalPrismaClient.$replica().$queryRaw<Array<{ onboardingStatus: string }>>(Prisma.sql`
SELECT "onboardingStatus"
FROM "Project"
WHERE "id" = ${projectId}
LIMIT 1
`);
apps/backend/src/lib/managed-email-domains.tsx:112
- This lookup is used as an existence check before creating external resources (Resend domain, DNSimple zone/records) in managed-email-onboarding. If the replica lags, it may not see an already-created ManagedEmailDomain row and the code can attempt duplicate external creation (often failing with a misleading 409) or hit the unique constraint later. Consider keeping this query on the primary (or providing a separate strongly-consistent variant for pre-write checks).
export async function getManagedEmailDomainByTenancyAndSubdomain(options: {
tenancyId: string,
subdomain: string,
}): Promise<ManagedEmailDomain | null> {
const rows = await globalPrismaClient.$replica().$queryRaw<ManagedEmailDomainRow[]>(Prisma.sql`
SELECT *
FROM "ManagedEmailDomain"
WHERE "tenancyId" = ${options.tenancyId}
AND "subdomain" = ${options.subdomain}
LIMIT 1
`);
apps/backend/src/lib/managed-email-domains.tsx:125
- This lookup is used by applyManagedEmailProvider to decide whether to proceed with state transitions (demote/mark applied, save config). Replica lag can cause stale status checks (e.g., still seeing VERIFIED after it was marked APPLIED, or missing the row right after creation/webhook update), which can lead to incorrect behavior or confusing errors. Prefer primary reads for these state-machine / pre-write checks, or split into a primary-consistent helper for callers that require it.
export async function getManagedEmailDomainByResendDomainId(resendDomainId: string): Promise<ManagedEmailDomain | null> {
const rows = await globalPrismaClient.$replica().$queryRaw<ManagedEmailDomainRow[]>(Prisma.sql`
SELECT *
FROM "ManagedEmailDomain"
WHERE "resendDomainId" = ${resendDomainId}
LIMIT 1
`);
apps/backend/src/lib/development-environment.ts:17
- This value is used as a gate before allowing environment-config override writes. Reading it from the replica can temporarily allow writes that should be blocked (or block writes that should be allowed) if the project was just marked/unmarked as a development environment and replication is behind. Consider keeping this check on the primary for correctness of write blocking.
export async function isDevelopmentEnvironmentProject(projectId: string): Promise<boolean> {
const rows = await globalPrismaClient.$replica().$queryRaw<Array<{ isDevelopmentEnvironment: boolean }>>(Prisma.sql`
SELECT "isDevelopmentEnvironment"
FROM "Project"
WHERE "id" = ${projectId}
LIMIT 1
`);
return rows[0]?.isDevelopmentEnvironment === true;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| ### Code-related | ||
| - Use ES6 maps instead of records wherever you can. | ||
| - **Read replicas for raw Prisma queries**: When writing raw SQL queries (`$queryRaw`, `$queryRawUnsafe`), always use `$replica()` for read-only queries (e.g. `globalPrismaClient.$replica().$queryRaw\`SELECT ...\``). This routes reads to the database replica and reduces load on the primary. Do NOT use `$replica()` for queries inside transactions or queries containing writes (INSERT/UPDATE/DELETE, even in CTEs). |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8aed98d. Configure here.
| const onboardingStateColumnExists = onboardingStateColumnExistsRows[0]?.exists === true; | ||
|
|
||
| const rows = await globalPrismaClient.$queryRaw<Array<{ onboardingStatus: string }>>(Prisma.sql` | ||
| const rows = await globalPrismaClient.$replica().$queryRaw<Array<{ onboardingStatus: string }>>(Prisma.sql` |
There was a problem hiding this comment.
Read-after-write from replica in same request fails
Medium Severity
syncLocalEmulatorOnboardingStatus reads the Project table from the replica via $replica().$queryRaw, but the project may have just been created on the primary by getOrCreateLocalEmulatorProjectId in the same request (line 325→327). When the replica hasn't caught up, the query returns zero rows and throws a StackAssertionError, surfacing as a 500 to the user. The same pattern at line 86 can cause getOrCreateLocalEmulatorProjectId to miss an existing mapping and orphan the old project. Before this PR both reads hit the primary, so no replication lag was possible. The default replication wait strategy is "none", meaning no wait occurs even when a real replica is configured.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 8aed98d. Configure here.


Routes all read-only raw Prisma queries (
$queryRaw/$queryRawUnsafe) to the database read replica via$replica(). Adds an AGENTS.md note documenting when to use$replica()for raw queries.Queries intentionally left on primary: queries inside transactions and write queries (INSERT/UPDATE/DELETE, including CTEs).
Link to Devin session: https://app.devin.ai/sessions/58c157f791f0424498bab3a92bc50f3e
Requested by: @N2D4
Summary by CodeRabbit
Note
Medium Risk
Broadly changes database read routing for several backend endpoints (including auth and payments) from primary to replica, which could surface replication-lag consistency issues if any of these reads implicitly expect read-after-write behavior.
Overview
Routes read-only raw SQL calls (
$queryRaw) to the database replica by switching to$replica().$queryRawacross multiple backend areas: CLI auth routes, external DB sync status aggregation, local emulator project helpers, payments transaction listing, session replay admin queries, conversations reads, and development-environment checks.Adds an
AGENTS.mdguideline documenting when to use$replica()for raw queries and when not to (transactions or any write-containing SQL).Reviewed by Cursor Bugbot for commit 8aed98d. Bugbot is set up for automated code reviews on this repo. Configure here.