From bb61875665c0f322eb3f153a7091aa63d5843831 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:35:14 -0400 Subject: [PATCH 1/2] fix: surface API errors in logs and MCP validation failures as 400s --- .../custom-automations-routes.test.ts | 298 ++++++++++++++++++ .../src/handlers/custom-automations/index.ts | 269 +++++++++++----- apps/api/src/server.ts | 11 + 3 files changed, 500 insertions(+), 78 deletions(-) create mode 100644 apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts new file mode 100644 index 000000000..9e4d1e85c --- /dev/null +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -0,0 +1,298 @@ +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import type { AuthTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; +import type { McpAuth } from '../../mcp/middleware'; +import { + customAutomationsRouter, + DUPLICATE_AUTOMATION_NAME_ERROR, +} from '../index'; + +const { + mockUsersFindFirst, + mockResolveActingUserIdOrNull, + mockCreateCustomAutomation, + mockUpdateCustomAutomation, + mockGetCustomAutomationById, + mockListCustomAutomations, + mockDeleteCustomAutomation, + mockListConnectedCommunicationProviders, + mockResolveCustomAutomationSchedule, + mockRunCustomAutomationNow, +} = vi.hoisted(() => ({ + mockUsersFindFirst: vi.fn(), + mockResolveActingUserIdOrNull: vi.fn(), + mockCreateCustomAutomation: vi.fn(), + mockUpdateCustomAutomation: vi.fn(), + mockGetCustomAutomationById: vi.fn(), + mockListCustomAutomations: vi.fn(), + mockDeleteCustomAutomation: vi.fn(), + mockListConnectedCommunicationProviders: vi.fn(), + mockResolveCustomAutomationSchedule: vi.fn(), + mockRunCustomAutomationNow: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + eq: vi.fn((...args: unknown[]) => ({ type: 'eq', args })), + isNull: vi.fn((arg: unknown) => ({ type: 'isNull', arg })), + users: { id: 'users.id', role: 'users.role', deletedAt: 'users.deletedAt' }, + db: { query: { users: { findFirst: mockUsersFindFirst } } }, + createCustomAutomation: mockCreateCustomAutomation, + updateCustomAutomation: mockUpdateCustomAutomation, + deleteCustomAutomation: mockDeleteCustomAutomation, + getCustomAutomationById: mockGetCustomAutomationById, + listCustomAutomations: mockListCustomAutomations, +})); + +vi.mock('@roomote/sdk/server', () => ({ + listConnectedCommunicationProviders: mockListConnectedCommunicationProviders, + resolveCustomAutomationSchedule: mockResolveCustomAutomationSchedule, + runCustomAutomationNow: mockRunCustomAutomationNow, +})); + +vi.mock('../../mcp/proxy-utils', () => ({ + resolveActingUserIdOrNull: mockResolveActingUserIdOrNull, +})); + +const ENVIRONMENT_ID = '00000000-0000-0000-0000-000000000001'; + +function createApp() { + const app = new Hono<{ Variables: Variables & { mcpAuth: McpAuth } }>(); + + // Mirrors the generic-error branch of `app.onError` in + // apps/api/src/server.ts: routes rethrow unexpected errors so the app-level + // handler logs them and returns an opaque 500. + const onError = vi.fn((_error: Error, c: Context) => + c.json({ error: 'internal_server_error' }, 500), + ); + app.onError(onError); + + app.use('*', async (c, next) => { + const authContext: AuthTokenContext = { + userId: 'admin-1', + tokenType: 'auth', + version: 1, + }; + c.set('mcpAuth', { userId: 'admin-1', authContext }); + await next(); + }); + app.route('/custom-automations', customAutomationsRouter); + + return { app, onError }; +} + +function createBody(overrides: Record = {}) { + return { + name: 'Nightly report', + prompt: 'Summarize yesterday.', + schedule: 'daily', + environmentId: ENVIRONMENT_ID, + ...overrides, + }; +} + +function postCreate( + app: ReturnType['app'], + body: Record, +) { + return app.request('/custom-automations', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('custom-automations MCP routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveActingUserIdOrNull.mockResolvedValue('admin-1'); + mockUsersFindFirst.mockResolvedValue({ id: 'admin-1' }); + mockListConnectedCommunicationProviders.mockResolvedValue(['slack']); + }); + + describe('POST / (create)', () => { + it('returns 400 with the message when the environment does not exist', async () => { + const { app } = createApp(); + mockCreateCustomAutomation.mockRejectedValue( + new Error('Selected environment was not found.'), + ); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Selected environment was not found.', + }); + }); + + it('returns 400 with a friendly message for a duplicate name', async () => { + const { app } = createApp(); + const dbError = Object.assign( + new Error( + 'duplicate key value violates unique constraint "custom_automations_name_unique_idx"', + ), + { code: '23505', constraint: 'custom_automations_name_unique_idx' }, + ); + mockCreateCustomAutomation.mockRejectedValue(dbError); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: DUPLICATE_AUTOMATION_NAME_ERROR, + }); + }); + + it('detects a duplicate name when drizzle wraps the driver error', async () => { + const { app } = createApp(); + const wrapped = new Error('Failed query: insert into custom_automations'); + (wrapped as { cause?: unknown }).cause = Object.assign( + new Error('duplicate key value violates unique constraint'), + { code: '23505', constraint: 'custom_automations_name_unique_idx' }, + ); + mockCreateCustomAutomation.mockRejectedValue(wrapped); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: DUPLICATE_AUTOMATION_NAME_ERROR, + }); + }); + + it('returns 400 with the message when the automation cap is reached', async () => { + const { app } = createApp(); + mockCreateCustomAutomation.mockRejectedValue( + new Error('You can create at most 25 custom automations.'), + ); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'You can create at most 25 custom automations.', + }); + }); + + it('rethrows unexpected errors so the app-level handler returns 500', async () => { + const { app, onError } = createApp(); + const unexpected = new Error('connection refused'); + mockCreateCustomAutomation.mockRejectedValue(unexpected); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'internal_server_error' }); + expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); + }); + }); + + describe('PATCH /:id (update)', () => { + const existing = { + id: 'automation-1', + name: 'Nightly report', + prompt: 'Summarize yesterday.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + environmentId: ENVIRONMENT_ID, + target: {}, + }; + + it('returns 400 with the message for a known validation failure', async () => { + const { app } = createApp(); + mockGetCustomAutomationById.mockResolvedValue(existing); + mockUpdateCustomAutomation.mockRejectedValue( + new Error('Selected environment was not found.'), + ); + + const res = await app.request('/custom-automations/automation-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ environmentId: ENVIRONMENT_ID }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Selected environment was not found.', + }); + }); + + it('returns 400 with a friendly message for a duplicate name', async () => { + const { app } = createApp(); + mockGetCustomAutomationById.mockResolvedValue(existing); + mockUpdateCustomAutomation.mockRejectedValue( + Object.assign(new Error('duplicate key value'), { + code: '23505', + constraint: 'custom_automations_name_unique_idx', + }), + ); + + const res = await app.request('/custom-automations/automation-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Taken name' }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: DUPLICATE_AUTOMATION_NAME_ERROR, + }); + }); + + it('rethrows unexpected errors so the app-level handler returns 500', async () => { + const { app, onError } = createApp(); + mockGetCustomAutomationById.mockResolvedValue(existing); + const unexpected = new Error('connection refused'); + mockUpdateCustomAutomation.mockRejectedValue(unexpected); + + const res = await app.request('/custom-automations/automation-1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Renamed' }), + }); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'internal_server_error' }); + expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); + }); + }); + + describe('POST /resolve-schedule', () => { + it('returns 400 with the message for a known schedule validation failure', async () => { + const { app } = createApp(); + mockResolveCustomAutomationSchedule.mockRejectedValue( + new Error('Use a standard five-field cron expression.'), + ); + + const res = await app.request('/custom-automations/resolve-schedule', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ schedule: 'every day at noon' }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Use a standard five-field cron expression.', + }); + }); + + it('rethrows unexpected resolution failures so the app-level handler returns 500', async () => { + const { app, onError } = createApp(); + const unexpected = new Error('LLM request failed'); + mockResolveCustomAutomationSchedule.mockRejectedValue(unexpected); + + const res = await app.request('/custom-automations/resolve-schedule', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ schedule: 'every day at noon' }), + }); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'internal_server_error' }); + expect(onError).toHaveBeenCalledWith(unexpected, expect.anything()); + }); + }); +}); diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index 4eb15bf18..e0e5be702 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import type { Context } from 'hono'; import { z } from 'zod'; import { @@ -59,6 +60,97 @@ const updateSchema = z.object({ targetServiceUrl: z.string().trim().min(1).max(500).optional(), }); +const UNIQUE_VIOLATION_CODE = '23505'; +const NAME_UNIQUE_INDEX = 'custom_automations_name_unique_idx'; +export const DUPLICATE_AUTOMATION_NAME_ERROR = + 'A custom automation with this name already exists.'; + +/** + * Whether the error (or anything in its cause chain — drizzle wraps the + * driver error in a DrizzleQueryError) is the Postgres unique violation for + * the custom automation name index. + */ +function isDuplicateNameViolation(error: unknown): boolean { + for ( + let current = error, depth = 0; + current !== null && current !== undefined && depth < 10; + depth += 1 + ) { + const candidate = current as { + code?: unknown; + constraint?: unknown; + message?: unknown; + cause?: unknown; + }; + + if ( + candidate.code === UNIQUE_VIOLATION_CODE || + candidate.constraint === NAME_UNIQUE_INDEX || + (typeof candidate.message === 'string' && + candidate.message.includes(NAME_UNIQUE_INDEX)) + ) { + return true; + } + + current = candidate.cause; + } + + return false; +} + +/** + * Expected validation failures thrown as plain Errors by + * `createCustomAutomation` / `updateCustomAutomation` (packages/db), + * `buildTarget`, and schedule validation (packages/sdk). These are safe to + * echo to the admin-only MCP client so the calling agent can self-correct; + * the web tRPC surface already shows the same messages to admins. Anything + * not matched here is rethrown so the app-level onError handler logs it and + * returns a generic 500. + */ +const VALIDATION_ERROR_PATTERNS: RegExp[] = [ + /^Name is required\.$/, + /^Name must be at most \d+ characters\.$/, + /^Prompt is required\.$/, + /^Prompt must be at most \d+ characters\.$/, + /^Invalid schedule mode: /, + /^Cron expression is required for a cron schedule\.$/, + /^Cron expression is only valid for a cron schedule\.$/, + /^Cron expression must be at most \d+ characters\.$/, + /^Cron expression must be between 1 and \d+ characters\.$/, + /^Use a standard five-field cron expression\.$/, + /^Environment is required\.$/, + /^Selected environment was not found\.$/, + /^Custom automation was not found\.$/, + /^Report destination must include a provider, target kind, and channel\.$/, + /^You can create at most \d+ custom automations\.$/, + /^targetChannelId is required when targetProvider is set\.$/, + /^Timezone is required\.$/, + /^Choose a valid IANA timezone\.$/, +]; + +/** + * Translate an expected validation failure into a 400 response with the + * message, or return null when the error is not a known validation failure + * (callers rethrow those so onError logs them as unexpected 500s). + */ +function knownErrorResponse( + c: Pick, + error: unknown, +): Response | null { + if (isDuplicateNameViolation(error)) { + return c.json({ error: DUPLICATE_AUTOMATION_NAME_ERROR }, 400); + } + + if ( + error instanceof Error && + VALIDATION_ERROR_PATTERNS.some((pattern) => pattern.test(error.message)) + ) { + return c.json({ error: error.message }, 400); + } + + return null; +} + async function requireAdmin(auth: McpAuth): Promise { let userId: string | null; try { @@ -167,41 +259,56 @@ customAutomationsRouter.post('/resolve-schedule', async (c) => { .object({ schedule: z.string().trim().min(1).max(500) }) .safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: parsed.error.message }, 400); - return c.json( - await resolveCustomAutomationSchedule({ - schedule: parsed.data.schedule, - userId: adminId(c), - }), - ); + try { + return c.json( + await resolveCustomAutomationSchedule({ + schedule: parsed.data.schedule, + userId: adminId(c), + }), + ); + } catch (error) { + const known = knownErrorResponse(c, error); + if (known) return known; + throw error; + } }); customAutomationsRouter.post('/', async (c) => { const parsed = writeSchema.safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: parsed.error.message }, 400); - const schedule = await resolveWriteSchedule(parsed.data.schedule, adminId(c)); - if (schedule.status === 'ambiguous') return c.json(schedule, 409); + try { + const schedule = await resolveWriteSchedule( + parsed.data.schedule, + adminId(c), + ); + if (schedule.status === 'ambiguous') return c.json(schedule, 409); - if (parsed.data.targetProvider) { - const connected = await listConnectedCommunicationProviders(); - if (!connected.includes(parsed.data.targetProvider)) { - return c.json( - { error: `${parsed.data.targetProvider} is not connected.` }, - 400, - ); + if (parsed.data.targetProvider) { + const connected = await listConnectedCommunicationProviders(); + if (!connected.includes(parsed.data.targetProvider)) { + return c.json( + { error: `${parsed.data.targetProvider} is not connected.` }, + 400, + ); + } } - } - const automation = await createCustomAutomation({ - name: parsed.data.name, - prompt: parsed.data.prompt, - enabled: parsed.data.enabled, - scheduleMode: schedule.scheduleMode, - cronExpression: schedule.cronExpression, - environmentId: parsed.data.environmentId, - target: buildTarget(parsed.data), - createdByUserId: adminId(c), - }); - return c.json({ automation, resolution: schedule.resolution }, 201); + const automation = await createCustomAutomation({ + name: parsed.data.name, + prompt: parsed.data.prompt, + enabled: parsed.data.enabled, + scheduleMode: schedule.scheduleMode, + cronExpression: schedule.cronExpression, + environmentId: parsed.data.environmentId, + target: buildTarget(parsed.data), + createdByUserId: adminId(c), + }); + return c.json({ automation, resolution: schedule.resolution }, 201); + } catch (error) { + const known = knownErrorResponse(c, error); + if (known) return known; + throw error; + } }); customAutomationsRouter.patch('/:id', async (c) => { @@ -211,59 +318,65 @@ customAutomationsRouter.patch('/:id', async (c) => { if (!existing) { return c.json({ error: 'Custom automation was not found.' }, 404); } - const schedule = parsed.data.schedule - ? await resolveWriteSchedule(parsed.data.schedule, adminId(c)) - : { - status: 'resolved' as const, - scheduleMode: existing.scheduleMode as CustomAutomationScheduleMode, - cronExpression: existing.cronExpression, - resolution: null, - }; - if (schedule.status === 'ambiguous') return c.json(schedule, 409); - if (parsed.data.targetProvider) { - const connected = await listConnectedCommunicationProviders(); - if (!connected.includes(parsed.data.targetProvider)) { - return c.json( - { error: `${parsed.data.targetProvider} is not connected.` }, - 400, - ); + try { + const schedule = parsed.data.schedule + ? await resolveWriteSchedule(parsed.data.schedule, adminId(c)) + : { + status: 'resolved' as const, + scheduleMode: existing.scheduleMode as CustomAutomationScheduleMode, + cronExpression: existing.cronExpression, + resolution: null, + }; + if (schedule.status === 'ambiguous') return c.json(schedule, 409); + if (parsed.data.targetProvider) { + const connected = await listConnectedCommunicationProviders(); + if (!connected.includes(parsed.data.targetProvider)) { + return c.json( + { error: `${parsed.data.targetProvider} is not connected.` }, + 400, + ); + } } + const existingTarget = existing.target; + const clearTarget = parsed.data.targetProvider === null; + const targetProvider = + parsed.data.targetProvider ?? + (existingTarget.provider === 'slack' || + existingTarget.provider === 'discord' || + existingTarget.provider === 'teams' || + existingTarget.provider === 'telegram' + ? existingTarget.provider + : undefined); + const targetChannelId = + parsed.data.targetChannelId ?? existingTarget.externalRef ?? undefined; + const existingServiceUrl = + typeof existingTarget.metadata?.serviceUrl === 'string' + ? existingTarget.metadata.serviceUrl + : undefined; + const automation = await updateCustomAutomation(c.req.param('id'), { + name: parsed.data.name ?? existing.name, + prompt: parsed.data.prompt ?? existing.prompt, + enabled: parsed.data.enabled ?? existing.enabled, + scheduleMode: schedule.scheduleMode, + cronExpression: schedule.cronExpression, + environmentId: parsed.data.environmentId ?? existing.environmentId ?? '', + target: clearTarget + ? {} + : targetProvider && targetChannelId + ? buildTarget({ + targetProvider, + targetChannelId, + targetServiceUrl: + parsed.data.targetServiceUrl ?? existingServiceUrl, + }) + : existingTarget, + }); + return c.json({ automation, resolution: schedule.resolution }); + } catch (error) { + const known = knownErrorResponse(c, error); + if (known) return known; + throw error; } - const existingTarget = existing.target; - const clearTarget = parsed.data.targetProvider === null; - const targetProvider = - parsed.data.targetProvider ?? - (existingTarget.provider === 'slack' || - existingTarget.provider === 'discord' || - existingTarget.provider === 'teams' || - existingTarget.provider === 'telegram' - ? existingTarget.provider - : undefined); - const targetChannelId = - parsed.data.targetChannelId ?? existingTarget.externalRef ?? undefined; - const existingServiceUrl = - typeof existingTarget.metadata?.serviceUrl === 'string' - ? existingTarget.metadata.serviceUrl - : undefined; - const automation = await updateCustomAutomation(c.req.param('id'), { - name: parsed.data.name ?? existing.name, - prompt: parsed.data.prompt ?? existing.prompt, - enabled: parsed.data.enabled ?? existing.enabled, - scheduleMode: schedule.scheduleMode, - cronExpression: schedule.cronExpression, - environmentId: parsed.data.environmentId ?? existing.environmentId ?? '', - target: clearTarget - ? {} - : targetProvider && targetChannelId - ? buildTarget({ - targetProvider, - targetChannelId, - targetServiceUrl: - parsed.data.targetServiceUrl ?? existingServiceUrl, - }) - : existingTarget, - }); - return c.json({ automation, resolution: schedule.resolution }); }); customAutomationsRouter.delete('/:id', async (c) => { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 954fe73cc..b146268dc 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -127,12 +127,23 @@ export function createApiApp(): ApiApp { app.onError((error, c: Context<{ Variables: Variables }>) => { if (error instanceof HTTPException) { if (error.status >= 500) { + // Sentry capture is a no-op when Sentry is disabled (the local-dev + // default), so always log server-side too — a 500 must never be + // invisible in the logs. + console.error( + `[api] Unhandled error ${c.req.method} ${c.req.path}:`, + error, + ); captureApiException(error, c); } return error.getResponse(); } + console.error( + `[api] Unhandled error ${c.req.method} ${c.req.path}:`, + error, + ); captureApiException(error, c); return c.json({ error: 'internal_server_error' }, { status: 500 }); From 6acd98c86217aa0815e65480e7f520fd32cd75c3 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:42:05 -0400 Subject: [PATCH 2/2] fix: require the name unique index before mapping 23505 to the duplicate-name error --- .../custom-automations-routes.test.ts | 20 +++++++++++++++++++ .../src/handlers/custom-automations/index.ts | 18 +++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index 9e4d1e85c..abd02df75 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -162,6 +162,26 @@ describe('custom-automations MCP routes', () => { }); }); + it('rethrows a 23505 on an unrelated constraint instead of mislabeling it as a duplicate name', async () => { + const { app, onError } = createApp(); + const unrelatedUniqueViolation = Object.assign( + new Error( + 'duplicate key value violates unique constraint "environments_name_unique"', + ), + { code: '23505', constraint: 'environments_name_unique' }, + ); + mockCreateCustomAutomation.mockRejectedValue(unrelatedUniqueViolation); + + const res = await postCreate(app, createBody()); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'internal_server_error' }); + expect(onError).toHaveBeenCalledWith( + unrelatedUniqueViolation, + expect.anything(), + ); + }); + it('returns 400 with the message when the automation cap is reached', async () => { const { app } = createApp(); mockCreateCustomAutomation.mockRejectedValue( diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index e0e5be702..b2bf59970 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -68,9 +68,16 @@ export const DUPLICATE_AUTOMATION_NAME_ERROR = /** * Whether the error (or anything in its cause chain — drizzle wraps the * driver error in a DrizzleQueryError) is the Postgres unique violation for - * the custom automation name index. + * the custom automation name index specifically. Both signals are required: + * a 23505 on some other constraint is not a duplicate name and must rethrow + * to the logged 500 path instead of being mislabeled. The two signals may + * live on different levels of the cause chain (wrapper message vs. driver + * error fields), so they are accumulated across the walk. */ function isDuplicateNameViolation(error: unknown): boolean { + let sawUniqueViolationCode = false; + let sawNameUniqueIndex = false; + for ( let current = error, depth = 0; current !== null && current !== undefined && depth < 10; @@ -83,12 +90,19 @@ function isDuplicateNameViolation(error: unknown): boolean { cause?: unknown; }; + if (candidate.code === UNIQUE_VIOLATION_CODE) { + sawUniqueViolationCode = true; + } + if ( - candidate.code === UNIQUE_VIOLATION_CODE || candidate.constraint === NAME_UNIQUE_INDEX || (typeof candidate.message === 'string' && candidate.message.includes(NAME_UNIQUE_INDEX)) ) { + sawNameUniqueIndex = true; + } + + if (sawUniqueViolationCode && sawNameUniqueIndex) { return true; }