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/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx b/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx index 444cca370..259f30a7e 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx @@ -5,7 +5,6 @@ import { ensureUpstashSignature } from "@/lib/upstash"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { getExternalDbSyncFusebox } from "@/lib/external-db-sync-metadata"; import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { traceSpan } from "@/utils/telemetry"; export const POST = createSmartRouteHandler({ @@ -45,7 +44,11 @@ export const POST = createSmartRouteHandler({ }); if (!tenancy) { console.warn(`[sync-engine] Tenancy ${tenancyId} in queue but not found, assuming it was deleted.`); - throw new StatusError(400, `Tenancy ${tenancyId} not found.`); + span.setAttribute("stack.external-db-sync.ignored-missing-tenancy", true); + return { + statusCode: 200, + bodyType: "success", + }; } const needsResync = await traceSpan("external-db-sync.sync-engine.syncExternalDatabases", async (syncSpan) => { 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..6090ea17e 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 @@ -1,7 +1,7 @@ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { afterAll, beforeAll, describe, expect } from 'vitest'; -import { test } from '../../../../helpers'; +import { niceFetch, STACK_BACKEND_BASE_URL, test } from '../../../../helpers'; import { withPortPrefix } from '../../../../helpers/ports'; import { Auth, backendContext, InternalApiKey, Project, User, niceBackendFetch } from '../../../backend-helpers'; import { randomUUID } from 'node:crypto'; @@ -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. @@ -1524,6 +1601,21 @@ describe.sequential('External DB Sync - Basic Tests', () => { }); }, TEST_TIMEOUT); + test('Sync engine ignores missing tenancy queue items', async () => { + const response = await niceFetch(new URL('/api/latest/internal/external-db-sync/sync-engine', STACK_BACKEND_BASE_URL), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'upstash-signature': 'test-bypass', + }, + body: JSON.stringify({ + tenancyId: randomUUID(), + }), + }); + + expect(response.status).toBe(200); + }, TEST_TIMEOUT); + /** * What it does: * - Signs up a user (which creates a refresh token), waits for it to sync to the external DB. 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.