From c6b101a40066fb8cb37342382679b4d56f39ddca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 26 Jul 2026 11:47:05 -0700 Subject: [PATCH 1/2] fix(tables): write single-cell edits as an atomic JSONB patch (fix row-level LWW) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table row stores every cell in one jsonb `data` column, and the row-edit paths called updateRow in the default replace mode — a full-object read-modify-write. Two users (or a user and an agent/copilot) editing DIFFERENT cells of the same row concurrently would clobber each other: whichever write committed last overwrote the whole row from its stale snapshot. Pass dataWriteMode: 'patch' (Postgres `data = data || patch::jsonb`) from the four partial-update callers — the UI row route, the v1 API row route, the copilot table tool, and the workflow-group cancel-write — so each cell edit merges atomically under the row lock. upsertRow (whole-row by design) and updateRowsByFilter (already uses ||) are unchanged. computedWrite stays unset on the user paths, preserving the update lock. Adds a route test asserting the UI route opts into patch mode; the updateRow patch mechanism itself is already covered. --- .../[tableId]/rows/[rowId]/route.test.ts | 82 +++++++++++++++++++ .../api/table/[tableId]/rows/[rowId]/route.ts | 9 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 7 +- .../copilot/tools/server/table/user-table.ts | 6 +- apps/sim/lib/table/workflow-columns.ts | 6 +- 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts new file mode 100644 index 00000000000..3fdf297e0c2 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuth, mockCheckAccess, mockUpdateRow } = vi.hoisted(() => ({ + mockAuth: vi.fn(), + mockCheckAccess: vi.fn(), + mockUpdateRow: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockAuth, +})) + +vi.mock('@/lib/table', () => ({ + updateRow: mockUpdateRow, + deleteRow: vi.fn(), +})) + +vi.mock('@/app/api/table/row-wire', () => ({ + rowWireTranslators: () => ({ + dataIn: (data: unknown) => data, + dataOut: (data: unknown) => data, + }), +})) + +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + rowWriteErrorResponse: () => undefined, + tableLockErrorResponse: () => undefined, + rootErrorMessage: (error: unknown) => (error instanceof Error ? error.message : ''), + } +}) + +import { PATCH } from '@/app/api/table/[tableId]/rows/[rowId]/route' + +const TABLE = { + id: 'tbl_1', + name: 'People', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col_1', name: 'name', type: 'text' }] }, +} + +function patchRequest(body: unknown) { + return new NextRequest('http://localhost:3000/api/table/tbl_1/rows/row_1', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('PATCH /api/table/[tableId]/rows/[rowId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ success: true, userId: 'user-1', authType: 'session' }) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockUpdateRow.mockResolvedValue({ + id: 'row_1', + data: { col_1: 'Bob' }, + position: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + }) + }) + + it("writes only the edited cells (dataWriteMode: 'patch') so a concurrent edit to another cell of the same row is not clobbered", async () => { + const res = await PATCH(patchRequest({ workspaceId: 'ws-1', data: { col_1: 'Bob' } }), { + params: Promise.resolve({ tableId: 'tbl_1', rowId: 'row_1' }), + }) + + expect(res.status).toBe(200) + expect(mockUpdateRow).toHaveBeenCalledTimes(1) + // The 4th argument is the options object; it must opt into cell-atomic JSONB patch mode. + expect(mockUpdateRow.mock.calls[0][3]).toEqual({ dataWriteMode: 'patch' }) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 82ec048ca84..a6aa02ddc43 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -133,6 +133,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema) + // Write only the edited cells via a Postgres JSONB concat (`data = data || patch`) instead of + // replacing the whole `data` object. A table row stores every cell in one jsonb column, so a + // full-object replace is last-write-wins across the ROW: two users editing different cells of + // the same row concurrently would clobber each other. `patch` merges under the row lock, so + // each cell edit is atomic. `computedWrite` stays unset — this is a user edit, still subject to + // the normal update lock (unlike the workflow engine writing its own output cells). const updatedRow = await updateRow( { tableId, @@ -142,7 +148,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR actorUserId: authResult.userId, }, table, - requestId + requestId, + { dataWriteMode: 'patch' } ) // Only `null` when a `cancellationGuard` is supplied and the SQL guard // rejects the write — this route doesn't pass one, so reaching null is a bug. diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 2a7ea2fe7a5..1f94852b107 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -139,6 +139,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + // Patch only the edited cells (`data = data || patch`) so concurrent edits to different cells + // of the same row don't clobber each other — a row stores all its cells in one jsonb column, + // so a full-object replace is last-write-wins across the whole row. See the app route for the + // full rationale. const updatedRow = await updateRow( { tableId, @@ -148,7 +152,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR actorUserId, }, table, - requestId + requestId, + { dataWriteMode: 'patch' } ) // No `cancellationGuard` is passed here, so `updateRow` can't return null // from this caller. Defensive narrowing for TypeScript. diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index a88e1dee903..2706d1e0572 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -727,6 +727,9 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const idByName = buildIdByName(table.schema) const toNamedRow = namedRowMapper(table.schema.columns) + // Patch only the edited cells so a copilot edit doesn't clobber a concurrent user edit + // to a different cell of the same row (all cells share one jsonb column — a full-object + // replace is last-write-wins across the row). See the table row route for the rationale. const updatedRow = await updateRow( { tableId: args.tableId, @@ -736,7 +739,8 @@ export const userTableServerTool: BaseServerTool actorUserId: context.userId, }, table, - requestId + requestId, + { dataWriteMode: 'patch' } ) if (!updatedRow) { // Only the cell-task path passes a `cancellationGuard`; this caller diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 9e0f35fc172..c0533306a7f 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -627,7 +627,11 @@ export async function cancelWorkflowGroupRuns( executionsPatch: m.executionsPatch, }, table, - `wfgrp-cancel-${m.rowId}` + `wfgrp-cancel-${m.rowId}`, + // This write only touches execution state (data is `{}`). `patch` makes the data write a + // true no-op (`data || '{}'`), whereas the default replace would rewrite the whole jsonb + // column and could revert a user's concurrent edit to a cell of the same row. + { dataWriteMode: 'patch' } ).catch((err) => { logger.error(`Failed to write cancelled state for row ${m.rowId}:`, err) }) From 7aff1ee1d1e05b779aa35ffafffa727ff89e78f9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 26 Jul 2026 11:52:35 -0700 Subject: [PATCH 2/2] test(tables): drop banned inline error-message pattern in route test mock --- apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts index 3fdf297e0c2..35d16175971 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts @@ -34,7 +34,7 @@ vi.mock('@/app/api/table/utils', async () => { NextResponse.json({ error: 'denied' }, { status: result.status }), rowWriteErrorResponse: () => undefined, tableLockErrorResponse: () => undefined, - rootErrorMessage: (error: unknown) => (error instanceof Error ? error.message : ''), + rootErrorMessage: () => '', } })