From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/3] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 49afd5f8b63fc8f4ae546c995430515e545983d9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 15:58:24 -0700 Subject: [PATCH 2/3] fix(mothership): tenant-check outputTable writes and route them through replaceTableRows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maybeWriteOutputToTable / maybeWriteReadCsvToTable accepted any table id without verifying it belongs to the caller's workspace, so a foreign table's rows could be wiped and replaced cross-tenant. They also wrote rows with raw drizzle keyed by column *name*, bypassing the service layer's job-slot lock, validation, plan row limits, rowCount maintenance, and the stable column-id keying every other writer uses. Both handlers now reject tables outside the caller's workspace and delegate to replaceTableRows with name→id remapped rows. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/request/tools/tables.test.ts | 181 ++++++++++++++++++ apps/sim/lib/copilot/request/tools/tables.ts | 109 +++++------ 2 files changed, 227 insertions(+), 63 deletions(-) create mode 100644 apps/sim/lib/copilot/request/tools/tables.test.ts diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts new file mode 100644 index 00000000000..4238bb63888 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockReplaceTableRows: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: mockGetTableById, + replaceTableRows: mockReplaceTableRows, +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withCopilotSpan: ( + _name: string, + _attrs: Record | undefined, + fn: (span: unknown) => Promise + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), +})) + +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { + maybeWriteOutputToTable, + maybeWriteReadCsvToTable, +} from '@/lib/copilot/request/tools/tables' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'col_name', name: 'name', type: 'string' }, + { id: 'col_age', name: 'age', type: 'number' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides, + } as TableDefinition +} + +function buildContext(overrides: Partial = {}): ExecutionContext { + return { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'workspace-1', + ...overrides, + } +} + +describe('maybeWriteOutputToTable', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + }) + + it('rejects a table from another workspace without touching it', async () => { + mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'Alice' }] } }, + buildContext() + ) + + expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + + it('replaces rows through the service with name keys remapped to column ids', async () => { + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { + success: true, + output: { + result: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 40 }, + ], + }, + }, + buildContext() + ) + + expect(result.success).toBe(true) + expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) + const [data, table] = mockReplaceTableRows.mock.calls[0] + expect(data).toMatchObject({ + tableId: 'tbl_1', + workspaceId: 'workspace-1', + userId: 'user-1', + rows: [ + { col_name: 'Alice', col_age: 30 }, + { col_name: 'Bob', col_age: 40 }, + ], + }) + expect(table.id).toBe('tbl_1') + }) + + it('fails fast when no row keys match the table columns', async () => { + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('None of the row keys match columns') + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + + it('surfaces service validation failures as tool errors', async () => { + mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ age: 30 }] } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Row 1: name is required') + }) +}) + +describe('maybeWriteReadCsvToTable', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + }) + + it('rejects a table from another workspace without touching it', async () => { + mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAlice,30' } }, + buildContext() + ) + + expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + + it('imports CSV content through the service with id-keyed rows', async () => { + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAlice,30\nBob,40' } }, + buildContext() + ) + + expect(result.success).toBe(true) + const [data] = mockReplaceTableRows.mock.calls[0] + expect(data.rows).toEqual([ + { col_name: 'Alice', col_age: '30' }, + { col_name: 'Bob', col_age: '40' }, + ]) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index f9d308df5b0..5eb50efb380 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,10 +1,7 @@ -import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { parse as csvParse } from 'csv-parse/sync' -import { eq } from 'drizzle-orm' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -12,14 +9,44 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { RowData } from '@/lib/table' -import { nKeysBetween } from '@/lib/table/order-key' -import { buildOrderedRowValues, getTableById } from '@/lib/table/service' +import type { RowData, TableDefinition } from '@/lib/table' +import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { getTableById, replaceTableRows } from '@/lib/table/service' const logger = createLogger('CopilotToolResultTables') const MAX_OUTPUT_TABLE_ROWS = 10_000 -const BATCH_CHUNK_SIZE = 500 + +/** + * Replaces a table's rows with wire rows keyed by column name. Translates the + * keys to stable column ids (unknown keys are dropped, matching every other + * name-translating boundary) and delegates to `replaceTableRows`, which owns + * locking, validation, plan row limits, batching, and rowCount maintenance. + */ +async function replaceTableRowsFromWire( + table: TableDefinition, + rows: Array>, + context: ExecutionContext +): Promise<{ error?: string }> { + const idByName = buildIdByName(table.schema) + const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName)) + if (idKeyedRows.every((row) => Object.keys(row).length === 0)) { + return { + error: `None of the row keys match columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, + } + } + await replaceTableRows( + { + tableId: table.id, + rows: idKeyedRows, + workspaceId: table.workspaceId, + userId: context.userId, + }, + table, + generateId().slice(0, 8) + ) + return {} +} export async function maybeWriteOutputToTable( toolName: string, @@ -44,7 +71,7 @@ export async function maybeWriteOutputToTable( async (span) => { try { const table = await getTableById(outputTable) - if (!table) { + if (!table || table.workspaceId !== context.workspaceId) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) return { success: false, @@ -97,33 +124,11 @@ export async function maybeWriteOutputToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - await db.transaction(async (tx) => { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) - - const now = new Date() - // Replace-all: table was just cleared — mint a fresh contiguous key run. - const orderKeys = nKeysBetween(null, null, rows.length) - for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = buildOrderedRowValues({ - tableId: outputTable, - workspaceId: context.workspaceId!, - rows: chunk as RowData[], - startPosition: i, - orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE), - now, - createdBy: context.userId, - makeId: () => `row_${generateId().replace(/-/g, '')}`, - }) - await tx.insert(userTableRows).values(values) - } - }) + const replaceResult = await replaceTableRowsFromWire(table, rows, context) + if (replaceResult.error) { + span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) + return { success: false, error: replaceResult.error } + } logger.info('Tool output written to table', { toolName, @@ -181,7 +186,7 @@ export async function maybeWriteReadCsvToTable( async (span) => { try { const table = await getTableById(outputTable) - if (!table) { + if (!table || table.workspaceId !== context.workspaceId) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) return { success: false, error: `Table "${outputTable}" not found` } } @@ -243,33 +248,11 @@ export async function maybeWriteReadCsvToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - await db.transaction(async (tx) => { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) - - const now = new Date() - // Replace-all: table was just cleared — mint a fresh contiguous key run. - const orderKeys = nKeysBetween(null, null, rows.length) - for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = buildOrderedRowValues({ - tableId: outputTable, - workspaceId: context.workspaceId!, - rows: chunk as RowData[], - startPosition: i, - orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE), - now, - createdBy: context.userId, - makeId: () => `row_${generateId().replace(/-/g, '')}`, - }) - await tx.insert(userTableRows).values(values) - } - }) + const replaceResult = await replaceTableRowsFromWire(table, rows, context) + if (replaceResult.error) { + span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) + return { success: false, error: replaceResult.error } + } logger.info('Read output written to table', { toolName, From 68c3ebb308ac2f75228ccd9d88354c5aa4c53cc8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 17:21:00 -0700 Subject: [PATCH 3/3] fix(mothership): fail outputTable writes per-row when any row matches no columns Review feedback: the all-rows-empty guard let mixed batches slip unmatched rows through as empty objects. Reject on the first row that maps to zero columns, naming the row. Adds the missing CSV-suite parity tests (no-matching-headers, service-error surfacing). Co-Authored-By: Claude Fable 5 --- .../lib/copilot/request/tools/tables.test.ts | 42 ++++++++++++++++++- apps/sim/lib/copilot/request/tools/tables.ts | 5 ++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 4238bb63888..e8a5be5877b 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -123,7 +123,20 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('None of the row keys match columns') + expect(result.error).toContain('Row 1 has no keys matching columns') + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + + it('fails fast when only some rows match instead of writing empty rows', async () => { + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Row 2 has no keys matching columns') expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -178,4 +191,31 @@ describe('maybeWriteReadCsvToTable', () => { { col_name: 'Bob', col_age: '40' }, ]) }) + + it('fails fast when the file headers match no table columns', async () => { + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'wrong,headers\n1,2' } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Row 1 has no keys matching columns') + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + + it('surfaces service validation failures as tool errors', async () => { + mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'age\n30' } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Row 1: name is required') + }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 5eb50efb380..ec1b72d6fa5 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -30,9 +30,10 @@ async function replaceTableRowsFromWire( ): Promise<{ error?: string }> { const idByName = buildIdByName(table.schema) const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName)) - if (idKeyedRows.every((row) => Object.keys(row).length === 0)) { + const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0) + if (emptyIndex !== -1) { return { - error: `None of the row keys match columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, + error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, } } await replaceTableRows(