From 359172d34098534a87dcadf0116e014b3033290d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 10:22:21 -0800 Subject: [PATCH 1/3] fix(errors): actionable table read timeouts, 409 for duplicate custom tools, Instagram revocation, selector failure logs --- apps/sim/app/api/tools/custom/route.test.ts | 41 ++++++++++++++++++- apps/sim/app/api/tools/custom/route.ts | 16 +++++++- apps/sim/lib/oauth/refresh-error-codes.ts | 4 +- apps/sim/lib/oauth/terminal-errors.test.ts | 8 ++++ .../lib/selectors/server/providers/http.ts | 32 +++++++++++++++ apps/sim/lib/table/application/rows.test.ts | 17 ++++++++ apps/sim/lib/table/application/rows.ts | 31 ++++++++++++-- apps/sim/lib/table/errors.ts | 1 + apps/sim/lib/table/planner.ts | 2 +- .../lib/workflows/custom-tools/operations.ts | 10 ++++- packages/testing/src/mocks/table.mock.ts | 1 + 11 files changed, 152 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 7a2b85a2218..78d8659dd06 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -15,8 +15,9 @@ import { import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpsertCustomTools } = vi.hoisted(() => ({ +const { mockUpsertCustomTools, MockCustomToolTitleConflictError } = vi.hoisted(() => ({ mockUpsertCustomTools: vi.fn(), + MockCustomToolTitleConflictError: class extends Error {}, })) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -82,11 +83,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/workflows/custom-tools/operations', () => ({ upsertCustomTools: (...args: unknown[]) => mockUpsertCustomTools(...args), + CustomToolTitleConflictError: MockCustomToolTitleConflictError, })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -import { DELETE } from '@/app/api/tools/custom/route' +import { DELETE, POST } from '@/app/api/tools/custom/route' describe('Custom Tools API Routes', () => { const mockSession = { user: { id: 'user-123' } } @@ -113,6 +115,41 @@ describe('Custom Tools API Routes', () => { resetDbChainMock() }) + describe('POST /api/tools/custom', () => { + function saveRequest() { + return new NextRequest('http://localhost:3000/api/tools/custom', { + method: 'POST', + body: JSON.stringify({ + workspaceId: 'workspace-123', + tools: [{ title: sampleTools[0].title, schema: sampleTools[0].schema, code: 'return 1' }], + }), + }) + } + + it('answers a title the workspace already holds with 409, not 500', async () => { + mockUpsertCustomTools.mockRejectedValueOnce( + new MockCustomToolTitleConflictError('A tool with that title already exists') + ) + + const response = await POST(saveRequest()) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'A tool with that title already exists' }) + }) + + it('answers a unique violation from a concurrent save with 409', async () => { + mockUpsertCustomTools.mockRejectedValueOnce( + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + ) + + const response = await POST(saveRequest()) + + expect(response.status).toBe(409) + }) + }) + /** * Test DELETE endpoint */ diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index baf4b307621..cea8e2f0626 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, desc, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -16,7 +16,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' +import { + CustomToolTitleConflictError, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomToolsAPI') @@ -176,6 +179,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, data: resultTools }) } catch (error) { + if (error instanceof CustomToolTitleConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'A custom tool with that title already exists in this workspace' }, + { status: 409 } + ) + } logger.error('Error updating custom tools', error) const errorMessage = getErrorMessage(error, 'Failed to update custom tools') return NextResponse.json({ error: errorMessage }, { status: 500 }) diff --git a/apps/sim/lib/oauth/refresh-error-codes.ts b/apps/sim/lib/oauth/refresh-error-codes.ts index 11f627840c4..9b60af552a1 100644 --- a/apps/sim/lib/oauth/refresh-error-codes.ts +++ b/apps/sim/lib/oauth/refresh-error-codes.ts @@ -26,10 +26,12 @@ const APP_CONFIGURATION_ERRORS = new Set([ * Credential revocation codes only for the providers listed. Atlassian rejects a revoked or * rotated-out refresh token with `unauthorized_client`; elsewhere that code usually describes the * app registration, and treating it as terminal would send every credential of the provider to - * reauthorization over one configuration fault. + * reauthorization over one configuration fault. Instagram's Graph API rejects a refresh of a token + * its user logged out of, revoked, or let expire with `OAuthException` code `190`. */ const PROVIDER_CREDENTIAL_REVOCATION_ERRORS: ReadonlyMap> = new Map([ ['confluence', new Set(['unauthorized_client'])], + ['instagram', new Set(['190'])], ['jira', new Set(['unauthorized_client'])], ]) diff --git a/apps/sim/lib/oauth/terminal-errors.test.ts b/apps/sim/lib/oauth/terminal-errors.test.ts index b0c2986f796..57e9c65cd18 100644 --- a/apps/sim/lib/oauth/terminal-errors.test.ts +++ b/apps/sim/lib/oauth/terminal-errors.test.ts @@ -68,6 +68,14 @@ describe('isCredentialRevocationError', () => { expect(isCredentialRevocationError('unauthorized_client', providerId)).toBe(false) } ) + + it.each([undefined, 'facebook', 'microsoft'])( + "treats Meta's code 190 as a revocation only for Instagram, not %s", + (providerId) => { + expect(isCredentialRevocationError('190', 'instagram')).toBe(true) + expect(isCredentialRevocationError('190', providerId)).toBe(false) + } + ) }) describe('markCredentialDead / getRecentTerminalError / clearDeadFlag', () => { diff --git a/apps/sim/lib/selectors/server/providers/http.ts b/apps/sim/lib/selectors/server/providers/http.ts index 00394622b00..df68573f0ca 100644 --- a/apps/sim/lib/selectors/server/providers/http.ts +++ b/apps/sim/lib/selectors/server/providers/http.ts @@ -1,9 +1,12 @@ +import { createLogger } from '@sim/logger' import { parseRetryAfter } from '@sim/utils/retry' import { SelectorConnectionUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' +const logger = createLogger('SelectorProviderHttp') + const PROVIDER_TIMEOUT_MS = 30_000 const MAX_PROVIDER_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -44,6 +47,32 @@ async function readBoundedBody(response: Response): Promise { return new TextDecoder().decode(body) } +/** Host and path of a provider request, never its query string, which can carry tokens. */ +function providerEndpoint(input: RequestInfo | URL): string | undefined { + try { + const url = new URL(input instanceof Request ? input.url : input) + return `${url.host}${url.pathname}` + } catch { + return undefined + } +} + +/** + * Records why a provider call failed before the error is reduced to a bare status, the only + * place the upstream status and endpoint are still known. + */ +function logProviderFailure( + input: RequestInfo | URL, + reason: 'status' | 'network' | 'invalid_response', + status?: number +): void { + logger.warn('Selector provider request failed', { + endpoint: providerEndpoint(input), + reason, + ...(status !== undefined ? { status } : {}), + }) +} + export type ProviderJsonStatusResult = | { ok: true; status: number; data: T } | { ok: false; status: number; retryAfterMs?: number } @@ -86,6 +115,7 @@ export async function fetchProviderJsonWithStatus( response = await fetch(input, { ...init, signal, redirect: init?.redirect ?? 'error' }) } catch (error) { if (init?.signal?.aborted) throw error + logProviderFailure(input, 'network') if (options.passthroughNetworkErrors) throw new RetryableProviderNetworkError() throw new SelectorOptionsUnavailableError() } @@ -103,6 +133,7 @@ export async function fetchProviderJsonWithStatus( ...(retryAfterMs !== null && retryAfterMs > 0 ? { retryAfterMs } : {}), } } + logProviderFailure(input, 'status', response.status) throw selectorProviderStatusError(response.status) } try { @@ -113,6 +144,7 @@ export async function fetchProviderJsonWithStatus( } } catch (error) { if (init?.signal?.aborted) throw error + logProviderFailure(input, 'invalid_response', response.status) if (error instanceof SelectorOptionsUnavailableError) throw error throw new SelectorOptionsUnavailableError() } diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index ba6f8635345..3876fe99893 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -445,6 +445,23 @@ describe('row query and upsert application semantics', () => { expect(mockQueryRows).not.toHaveBeenCalled() }) + it('turns a read cancelled by its statement timeout into an actionable 400', async () => { + mockQueryRows.mockRejectedValueOnce( + Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' }) + ) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 100 }, + }) + ).rejects.toMatchObject({ + code: 'validation', + details: { code: 'TABLE_QUERY_TIMEOUT' }, + message: expect.stringContaining('Sort by createdAt or updatedAt'), + }) + }) + it('resumes the same offset page under the identical predicate', async () => { const cursor = encodeCursor({ lastRow: { id: 'row-100', orderKey: null }, diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 832e40d09b5..a0266c3aef1 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -2,7 +2,8 @@ import { isDeepStrictEqual } from 'node:util' import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' -import { getRequestContext } from '@sim/logger' +import { createLogger, getRequestContext } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord, toRecord } from '@sim/utils/object' import { @@ -77,6 +78,7 @@ import { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' +import { READ_STATEMENT_TIMEOUT_MS } from '@/lib/table/planner' import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicate, @@ -98,6 +100,8 @@ import { predicateToStorage, resolveFilterSelectValues } from '@/lib/table/selec import { coerceRowValues } from '@/lib/table/validation' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +const logger = createLogger('TableRowsApplication') + const ROW_CHANGE_COUNTERS = ['rowsInserted', 'rowsUpdated', 'rowsDeleted'] as const /** @@ -501,6 +505,25 @@ async function throwValidationResponse( throw new TableRowsValidationError(body.error ?? 'Invalid row data', body.details) } +/** + * A read cancelled by its own `statement_timeout`: the query shape costs more than the table's + * size allows. Sorting or range-filtering on a stored column reads every row's data, so it is the + * caller's to narrow, and retrying the same query only times out again. + */ +function rethrowReadQueryError(error: unknown, tableId: string): never { + if (getPostgresErrorCode(error) === '57014') { + logger.warn('Table read hit its statement timeout', { tableId }) + throw new TableRowsValidationError( + `The query took longer than ${READ_STATEMENT_TIMEOUT_MS / 1000} seconds and was stopped. ` + + 'Sorting or range-filtering on a column reads every row in the table, which is too slow ' + + 'at this table size. Sort by createdAt or updatedAt, add an equality filter, or keep ' + + 'fewer rows (an Expiration column deletes old rows automatically).', + { code: 'TABLE_QUERY_TIMEOUT' } + ) + } + rethrowQueryValidation(error) +} + function rethrowQueryValidation(error: unknown): never { if (error instanceof TableQueryValidationError) { throw new TableRowsValidationError(error.message, error.code ? { code: error.code } : undefined) @@ -551,7 +574,7 @@ export const listTableRows = defineAuthorizedTableUseCase({ nextCursor: result.nextCursor, } } catch (error) { - rethrowQueryValidation(error) + rethrowReadQueryError(error, context.table.id) } }, }) @@ -680,7 +703,7 @@ export const queryTableRows = defineAuthorizedTableUseCase({ : {}), } } catch (error) { - rethrowQueryValidation(error) + rethrowReadQueryError(error, context.table.id) } }, }) @@ -720,7 +743,7 @@ export const searchTableRows = defineAuthorizedTableUseCase({ ) return { table: context.table, ...result } } catch (error) { - rethrowQueryValidation(error) + rethrowReadQueryError(error, context.table.id) } }, }) diff --git a/apps/sim/lib/table/errors.ts b/apps/sim/lib/table/errors.ts index d54cf0b4a69..3dbbc0808a7 100644 --- a/apps/sim/lib/table/errors.ts +++ b/apps/sim/lib/table/errors.ts @@ -9,6 +9,7 @@ export type TableQueryErrorCode = | 'CURSOR_FILTER_CONFLICT' | 'INVALID_FILTER' | 'INVALID_ORDER' + | 'TABLE_QUERY_TIMEOUT' /** * Error thrown when caller-supplied filter or sort input is malformed. diff --git a/apps/sim/lib/table/planner.ts b/apps/sim/lib/table/planner.ts index 5ec1a18916e..bfe7028ebf2 100644 --- a/apps/sim/lib/table/planner.ts +++ b/apps/sim/lib/table/planner.ts @@ -12,7 +12,7 @@ export type DbTransaction = Parameters[0]>[0] * small shared `web` pool — one abusive key can starve every tenant. `SET LOCAL` * scopes both to the surrounding transaction, so they die when it commits. */ -const READ_STATEMENT_TIMEOUT_MS = 15_000 +export const READ_STATEMENT_TIMEOUT_MS = 15_000 const READ_LOCK_TIMEOUT_MS = 3_000 /** diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 7c3961404a0..aa45fde07c7 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -23,6 +23,14 @@ const logger = createLogger('CustomToolsOperations') export type CustomToolSortBy = 'title' | 'createdAt' | 'updatedAt' +/** A strict batch write found a title the workspace already holds; the caller's to fix. */ +export class CustomToolTitleConflictError extends Error { + constructor(title: string) { + super(`A tool with the title "${title}" already exists in this workspace`) + this.name = 'CustomToolTitleConflictError' + } +} + interface CustomToolWriteParams { tools: Array<{ id?: string @@ -144,7 +152,7 @@ async function writeCustomTools( .limit(1) if (duplicateTitle.length > 0) { - throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + throw new CustomToolTitleConflictError(tool.title) } await tx.insert(customTools).values(row) diff --git a/packages/testing/src/mocks/table.mock.ts b/packages/testing/src/mocks/table.mock.ts index 34d22de51d2..ff82d4f3518 100644 --- a/packages/testing/src/mocks/table.mock.ts +++ b/packages/testing/src/mocks/table.mock.ts @@ -12,6 +12,7 @@ type MockTableQueryErrorCode = | 'CURSOR_FILTER_CONFLICT' | 'INVALID_FILTER' | 'INVALID_ORDER' + | 'TABLE_QUERY_TIMEOUT' interface MockColumnRef { id?: string From aaa75e630424beed9f5ba313643bc42b5ab740c9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 10:33:22 -0800 Subject: [PATCH 2/3] fix(errors): address review on table timeout, selector logging, and route test context --- apps/sim/app/api/tools/custom/route.test.ts | 5 +++-- apps/sim/lib/selectors/server/providers/http.ts | 15 ++++++++++++--- apps/sim/lib/table/application/rows.test.ts | 16 +++++++++++++++- apps/sim/lib/table/application/rows.ts | 6 +++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 78d8659dd06..748635d7c1e 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -3,6 +3,7 @@ */ import { authMockFns, + createRouteContext, hybridAuthMockFns, permissionsMock, permissionsMockFns, @@ -131,7 +132,7 @@ describe('Custom Tools API Routes', () => { new MockCustomToolTitleConflictError('A tool with that title already exists') ) - const response = await POST(saveRequest()) + const response = await POST(saveRequest(), createRouteContext({})) expect(response.status).toBe(409) expect(await response.json()).toEqual({ error: 'A tool with that title already exists' }) @@ -144,7 +145,7 @@ describe('Custom Tools API Routes', () => { }) ) - const response = await POST(saveRequest()) + const response = await POST(saveRequest(), createRouteContext({})) expect(response.status).toBe(409) }) diff --git a/apps/sim/lib/selectors/server/providers/http.ts b/apps/sim/lib/selectors/server/providers/http.ts index df68573f0ca..dc12d60b635 100644 --- a/apps/sim/lib/selectors/server/providers/http.ts +++ b/apps/sim/lib/selectors/server/providers/http.ts @@ -47,11 +47,14 @@ async function readBoundedBody(response: Response): Promise { return new TextDecoder().decode(body) } -/** Host and path of a provider request, never its query string, which can carry tokens. */ +/** + * Host and path of a provider request. The query string is dropped and OData-style path + * arguments (`search(q='...')`) are blanked, since both can carry tokens or a user's search text. + */ function providerEndpoint(input: RequestInfo | URL): string | undefined { try { const url = new URL(input instanceof Request ? input.url : input) - return `${url.host}${url.pathname}` + return `${url.host}${url.pathname.replace(/\([^/]*\)/g, '()')}` } catch { return undefined } @@ -144,7 +147,13 @@ export async function fetchProviderJsonWithStatus( } } catch (error) { if (init?.signal?.aborted) throw error - logProviderFailure(input, 'invalid_response', response.status) + logProviderFailure( + input, + error instanceof SyntaxError || error instanceof SelectorOptionsUnavailableError + ? 'invalid_response' + : 'network', + response.status + ) if (error instanceof SelectorOptionsUnavailableError) throw error throw new SelectorOptionsUnavailableError() } diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 3876fe99893..af85f01df8a 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -458,10 +458,24 @@ describe('row query and upsert application semantics', () => { ).rejects.toMatchObject({ code: 'validation', details: { code: 'TABLE_QUERY_TIMEOUT' }, - message: expect.stringContaining('Sort by createdAt or updatedAt'), + message: expect.stringContaining('Sort by createdAt'), }) }) + it('keeps a caller-cancelled read as the raw cancellation, not a timeout', async () => { + const cancelled = Object.assign(new Error('canceling statement due to user request'), { + code: '57014', + }) + mockQueryRows.mockRejectedValueOnce(cancelled) + + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 100 }, + }) + ).rejects.toBe(cancelled) + }) + it('resumes the same offset page under the identical predicate', async () => { const cursor = encodeCursor({ lastRow: { id: 'row-100', orderKey: null }, diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index a0266c3aef1..fbdcde840f8 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -3,7 +3,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { createLogger, getRequestContext } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getPostgresCancellationReason } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord, toRecord } from '@sim/utils/object' import { @@ -511,12 +511,12 @@ async function throwValidationResponse( * caller's to narrow, and retrying the same query only times out again. */ function rethrowReadQueryError(error: unknown, tableId: string): never { - if (getPostgresErrorCode(error) === '57014') { + if (getPostgresCancellationReason(error) === 'statement_timeout') { logger.warn('Table read hit its statement timeout', { tableId }) throw new TableRowsValidationError( `The query took longer than ${READ_STATEMENT_TIMEOUT_MS / 1000} seconds and was stopped. ` + 'Sorting or range-filtering on a column reads every row in the table, which is too slow ' + - 'at this table size. Sort by createdAt or updatedAt, add an equality filter, or keep ' + + 'at this table size. Sort by createdAt, add an equality filter, or keep ' + 'fewer rows (an Expiration column deletes old rows automatically).', { code: 'TABLE_QUERY_TIMEOUT' } ) From 44b78052236e3396ae98bdf5c09836cc053c4357 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 10:45:24 -0800 Subject: [PATCH 3/3] fix(table): limit timeout guidance to the structured row query --- apps/sim/lib/table/application/rows.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index fbdcde840f8..d955d5744c4 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -506,8 +506,8 @@ async function throwValidationResponse( } /** - * A read cancelled by its own `statement_timeout`: the query shape costs more than the table's - * size allows. Sorting or range-filtering on a stored column reads every row's data, so it is the + * A structured row query cancelled by its own `statement_timeout`: the query shape costs more than + * the table's size allows. Sorting or range-filtering on a stored column reads every row's data, so it is the * caller's to narrow, and retrying the same query only times out again. */ function rethrowReadQueryError(error: unknown, tableId: string): never { @@ -574,7 +574,7 @@ export const listTableRows = defineAuthorizedTableUseCase({ nextCursor: result.nextCursor, } } catch (error) { - rethrowReadQueryError(error, context.table.id) + rethrowQueryValidation(error) } }, }) @@ -743,7 +743,7 @@ export const searchTableRows = defineAuthorizedTableUseCase({ ) return { table: context.table, ...result } } catch (error) { - rethrowReadQueryError(error, context.table.id) + rethrowQueryValidation(error) } }, })