Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 68 additions & 27 deletions apps/sim/lib/table/__tests__/update-row.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* @vitest-environment node
*/
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { tableRowExecutions, userTableRows } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deleteColumn, renameColumn } from '@/lib/table/columns/service'
import {
batchInsertRows,
batchUpdateRows,
insertRow,
replaceTableRows,
updateRow,
Expand Down Expand Up @@ -64,6 +66,17 @@ function findExecutedRawSql(substring: string): string | undefined {
return undefined
}

/**
* The `data` payload of the last `.set(...)` row write. `updateRow` always writes a JSONB merge
* (`data = data || {changed}::jsonb`), so this is a `sql` fragment exposing `{ strings, values }`.
*/
function lastSetDataSql(): { strings?: string[]; values?: unknown[] } | undefined {
const payload = dbChainMockFns.set.mock.calls.at(-1)?.[0] as
| { data?: { strings?: string[]; values?: unknown[] } }
| undefined
return payload?.data
}

const EXISTING_ROW = {
id: 'row-1',
tableId: 'tbl-1',
Expand Down Expand Up @@ -109,10 +122,15 @@ describe('updateRow — partial merge', () => {
'req-1'
)

// The returned row is the full merge…
expect(result.data).toEqual({ name: 'Alice', age: 31 })
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'Alice', age: 31 } })
)
// …but the WRITE is a JSONB merge of only the changed cell (`data = data || {age:31}`), so the
// unchanged `name` column is never re-written — that's what keeps concurrent edits to different
// cells of the same row from clobbering each other.
const data = lastSetDataSql()
expect(data?.strings?.join('')).toContain(' || ')
expect(data?.values).toContain(JSON.stringify({ age: 31 }))
expect(data?.values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 }))
})

it('allows updating a single column without affecting others', async () => {
Expand All @@ -123,9 +141,7 @@ describe('updateRow — partial merge', () => {
)

expect(result.data).toEqual({ name: 'Bob', age: 30 })
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'Bob', age: 30 } })
)
expect(lastSetDataSql()?.values).toContain(JSON.stringify({ name: 'Bob' }))
})

it('allows explicitly nulling a field while preserving others', async () => {
Expand All @@ -136,9 +152,8 @@ describe('updateRow — partial merge', () => {
)

expect(result.data).toEqual({ name: 'Alice', age: null })
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'Alice', age: null } })
)
// A cleared cell is written as present-with-null, not dropped.
expect(lastSetDataSql()?.values).toContain(JSON.stringify({ age: null }))
})

it('handles a full-row update correctly (idempotent merge)', async () => {
Expand All @@ -151,23 +166,6 @@ describe('updateRow — partial merge', () => {
expect(result.data).toEqual({ name: 'Bob', age: 25 })
})

it('sends only changed keys in JSONB patch mode while returning merged data', async () => {
const result = await updateRow(
{ tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' },
TABLE,
'req-1',
{ dataWriteMode: 'patch' }
)

expect(result?.data).toEqual({ name: 'Alice', age: 31 })
const setPayload = dbChainMockFns.set.mock.calls.at(-1)?.[0] as
| { data?: { strings?: string[]; values?: unknown[] } }
| undefined
expect(setPayload?.data?.strings?.join('')).toContain(' || ')
expect(setPayload?.data?.values).toContain(JSON.stringify({ age: 31 }))
expect(setPayload?.data?.values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 }))
})

it('throws when the row does not exist', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])

Expand Down Expand Up @@ -419,3 +417,46 @@ describe('mutation paths — SET LOCAL timeouts', () => {
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
})
})

describe('batchUpdateRows — per-row partial merge', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

it('writes each row as a JSONB merge of only its changed cells', async () => {
queueTableRows(userTableRows, [
{ id: 'row-1', data: { name: 'Alice', age: 30 } },
{ id: 'row-2', data: { name: 'Carol', age: 40 } },
])
queueTableRows(tableRowExecutions, []) // loadExecutionsByRow → no sidecar rows

await batchUpdateRows(
{
tableId: 'tbl-1',
workspaceId: 'ws-1',
updates: [
{ rowId: 'row-1', data: { age: 31 } },
{ rowId: 'row-2', data: { name: 'Dave' } },
],
},
TABLE,
'req-1'
)

// Each row write is `data || {changed}::jsonb`, carrying ONLY that row's changed cell — so a
// batch edit can't clobber a concurrent edit to another cell of the same row.
const writes = dbChainMockFns.set.mock.calls
.map((c) => c[0] as { data?: { strings?: string[]; values?: unknown[] } })
.filter((p) => Array.isArray(p?.data?.strings))
expect(writes.length).toBe(2)
for (const w of writes) {
expect(w.data?.strings?.join('')).toContain(' || ')
}
const values = writes.flatMap((w) => w.data?.values ?? [])
expect(values).toContain(JSON.stringify({ age: 31 }))
expect(values).toContain(JSON.stringify({ name: 'Dave' }))
// Never the whole row.
expect(values).not.toContain(JSON.stringify({ name: 'Alice', age: 31 }))
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/table/cell-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe('writeWorkflowGroupState', () => {
},
TABLE,
CONTEXT.requestId,
{ dataWriteMode: 'patch', computedWrite: true }
{ computedWrite: true }
)
expect(mockAppendTableEvent).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/table/cell-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export async function writeWorkflowGroupState(
requestId,
// `computedWrite` is what lets a workflow column keep populating on an
// update-locked table; the lock still covers user-authored columns.
{ dataWriteMode: 'patch', computedWrite: true }
{ computedWrite: true }
)
} else {
result = await db.transaction((trx) =>
Expand Down
40 changes: 24 additions & 16 deletions apps/sim/lib/table/rows/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,11 +1169,6 @@ class GuardRejected extends Error {
* @throws Error if row not found or validation fails
*/
export interface UpdateRowOptions {
/**
* `patch` sends only changed keys to Postgres via JSONB concatenation while
* retaining the same merged-row validation and returned shape.
*/
dataWriteMode?: 'replace' | 'patch'
/**
* Marks the write as the workflow/enrichment engine filling its own output
* cells, which exempts it from the update lock. Set by `cell-write.ts` only —
Expand All @@ -1182,6 +1177,22 @@ export interface UpdateRowOptions {
computedWrite?: boolean
}

/**
* A row stores every cell in one jsonb `data` column, so a row update writes the changed cells as
* an in-DB JSONB merge (`data = data || {changed}::jsonb`) rather than replacing the whole object.
* Postgres evaluates the concat against the current committed row under its write lock, so
* concurrent edits to DIFFERENT cells of the same row both survive instead of the last writer
* clobbering the row from a stale read. Values come from the caller's already-coerced merged row;
* only the changed keys are sent, so an empty change set is a no-op on `data`. Whole-row
* replacement is `upsertRow`'s job, not this path (an update only ever adds/overwrites cells —
* it never deletes a key — so a full-object write would only differ by being racy).
*/
function jsonbMergePatch(changedColumnIds: string[], coercedRow: RowData): SQL {
const patch: RowData = {}
for (const columnId of changedColumnIds) patch[columnId] = coercedRow[columnId]
return sql`${userTableRows.data} || ${JSON.stringify(patch)}::jsonb`
}

export async function updateRow(
data: UpdateRowData,
table: TableDefinition,
Expand Down Expand Up @@ -1241,14 +1252,7 @@ export async function updateRow(
}

const now = new Date()
const persistedDataPatch: RowData = {}
for (const columnId of Object.keys(data.data)) {
persistedDataPatch[columnId] = mergedData[columnId]
}
const persistedData =
options.dataWriteMode === 'patch'
? sql`${userTableRows.data} || ${JSON.stringify(persistedDataPatch)}::jsonb`
: mergedData
const persistedData = jsonbMergePatch(Object.keys(data.data), mergedData)

// Cell-task partial writes pass `cancellationGuard` so the upsert into
// `tableRowExecutions` is a no-op when (a) a stop click already wrote
Expand Down Expand Up @@ -1597,6 +1601,7 @@ export async function batchUpdateRows(

const mergedUpdates: Array<{
rowId: string
changedColumnIds: string[]
mergedData: RowData
mergedExecutions: RowExecutions
executionsPatch?: Record<string, RowExecutionMetadata | null>
Expand Down Expand Up @@ -1631,6 +1636,7 @@ export async function batchUpdateRows(

mergedUpdates.push({
rowId: update.rowId,
changedColumnIds: Object.keys(update.data),
mergedData: merged,
mergedExecutions,
executionsPatch: effectiveExecutionsPatch,
Expand Down Expand Up @@ -1660,11 +1666,13 @@ export async function batchUpdateRows(
for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
// Update row data in parallel; sidecar exec writes are sequential per
// row (each goes through writeExecutionsPatch's per-key upsert).
const dataPromises = batch.map(({ rowId, mergedData }) =>
// row (each goes through writeExecutionsPatch's per-key upsert). Each row
// merges its changed cells via JSONB concat (see jsonbMergePatch) so a
// batch edit can't clobber a concurrent edit to another cell of the row.
const dataPromises = batch.map(({ rowId, changedColumnIds, mergedData }) =>
trx
.update(userTableRows)
.set({ data: mergedData, updatedAt: now })
.set({ data: jsonbMergePatch(changedColumnIds, mergedData), updatedAt: now })
.where(eq(userTableRows.id, rowId))
)
await Promise.all(dataPromises)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/table/workflow-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ export async function cancelWorkflowGroupRuns(
// re-enqueueing what we just cancelled.
await Promise.allSettled(
mutations.map((m) =>
// Only touches execution state — `data: {}` is a no-op on the row's cells (updateRow merges
// the empty patch), so this cancel can't revert a user's concurrent edit to the same row.
updateRow(
{
tableId,
Expand Down
Loading