From 2d34e4b84e34eca224507354162c81310c834df9 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 9 Apr 2026 23:30:20 -0700 Subject: [PATCH] Fix sequencer operator mismatch error --- .../external-db-sync/sequencer/route.ts | 2 +- .../api/v1/external-db-sync-basics.test.ts | 77 +++++++++++++++++++ claude/CLAUDE-KNOWLEDGE.md | 3 + 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index 0a35024ca..9130e30c0 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -153,7 +153,7 @@ async function backfillSequenceIds(batchSize: number): Promise { WHERE "VerificationCode"."projectId" = changed_teams."projectId" AND "VerificationCode"."branchId" = changed_teams."branchId" AND "VerificationCode"."type" = 'TEAM_INVITATION' - AND "VerificationCode"."data"->>'team_id' = changed_teams."teamId" + AND "VerificationCode"."data"->>'team_id' = changed_teams."teamId"::text AND "VerificationCode"."shouldUpdateSequenceId" = FALSE `; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts index 191c31068..ff34cdaeb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts @@ -1173,6 +1173,83 @@ describe.sequential('External DB Sync - Basic Tests', () => { await waitForSyncedTeamInvitationDeletion(client, invitationId); }, TEST_TIMEOUT); + /** + * What it does: + * - Sends a team invitation, renames the team, and verifies team_display_name updates externally. + * + * Why it matters: + * - Covers the sequencer cascade that marks TEAM_INVITATION rows for re-sync on team updates. + */ + test('TeamInvitation sync updates display name after team rename (Postgres)', async () => { + const dbName = 'team_invitation_team_rename_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }, { display_name: 'Invitation Rename Test Project' }); + + const client = dbManager.getClient(dbName); + const initialTeamName = 'Invitation Team Before Rename'; + const updatedTeamName = 'Invitation Team After Rename'; + const invitedEmail = `team-rename-${randomUUID()}@example.com`; + + const createTeamResponse = await niceBackendFetch('/api/v1/teams', { + accessType: 'admin', + method: 'POST', + body: { display_name: initialTeamName }, + }); + expect(createTeamResponse.status).toBe(201); + const teamId = createTeamResponse.body.id; + + const inviteResponse = await niceBackendFetch('/api/v1/team-invitations/send-code', { + accessType: 'admin', + method: 'POST', + body: { team_id: teamId, email: invitedEmail, callback_url: 'http://localhost:12345/callback' }, + }); + expect(inviteResponse.status).toBe(200); + + await waitForSyncedTeamInvitation(client, invitedEmail); + + const initialInvitation = await client.query( + `SELECT "team_display_name" FROM "team_invitations" WHERE "recipient_email" = $1`, + [invitedEmail], + ); + expect(initialInvitation.rows.length).toBe(1); + expect(initialInvitation.rows[0].team_display_name).toBe(initialTeamName); + + const updateTeamResponse = await niceBackendFetch(`/api/v1/teams/${teamId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: updatedTeamName }, + }); + expect(updateTeamResponse.status).toBe(200); + + await waitForCondition( + async () => { + const updatedInvitation = await client.query( + `SELECT "team_display_name" FROM "team_invitations" WHERE "recipient_email" = $1`, + [invitedEmail], + ); + return updatedInvitation.rows.length === 1 && updatedInvitation.rows[0].team_display_name === updatedTeamName; + }, + { + timeoutMs: 180_000, + intervalMs: 500, + description: `team invitation for ${invitedEmail} to reflect renamed team`, + }, + ); + + const finalInvitation = await client.query( + `SELECT "team_display_name" FROM "team_invitations" WHERE "recipient_email" = $1`, + [invitedEmail], + ); + expect(finalInvitation.rows.length).toBe(1); + expect(finalInvitation.rows[0].team_display_name).toBe(updatedTeamName); + }, TEST_TIMEOUT); + /** * What it does: * - Sends a team invitation, queries ClickHouse analytics API to verify. diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 0a399561b..5cbf4a426 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -170,3 +170,6 @@ A: In `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/vercel/pag Q: Why can restricted users appear logged out on auth handler pages even with a valid session? A: `useUser()` filters out restricted users by default. In `packages/template/src/components-page/auth-page.tsx`, use `useUser({ includeRestricted: true })` and explicitly redirect restricted users to onboarding when `automaticRedirect` is enabled. + +Q: Why can external-db-sync sequencer throw `operator does not exist: text = uuid` on team updates? +A: In `apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts`, the TEAM_INVITATION cascade compares JSON text (`"VerificationCode"."data"->>'team_id'`) against `"Team"."teamId"` (`uuid`). Cast the UUID side to text (`changed_teams."teamId"::text`) in the WHERE clause so Postgres type resolution succeeds and team-invitation re-sync marking works.