11/**
22 * @vitest -environment node
33 */
4- import { dbChainMockFns , resetDbChainMock } from '@sim/testing'
4+ import { tableRowExecutions , userTableRows } from '@sim/db/schema'
5+ import { dbChainMockFns , queueTableRows , resetDbChainMock } from '@sim/testing'
56import { beforeEach , describe , expect , it , vi } from 'vitest'
67import { deleteColumn , renameColumn } from '@/lib/table/columns/service'
78import {
89 batchInsertRows ,
10+ batchUpdateRows ,
911 insertRow ,
1012 replaceTableRows ,
1113 updateRow ,
@@ -64,6 +66,17 @@ function findExecutedRawSql(substring: string): string | undefined {
6466 return undefined
6567}
6668
69+ /**
70+ * The `data` payload of the last `.set(...)` row write. `updateRow` always writes a JSONB merge
71+ * (`data = data || {changed}::jsonb`), so this is a `sql` fragment exposing `{ strings, values }`.
72+ */
73+ function lastSetDataSql ( ) : { strings ?: string [ ] ; values ?: unknown [ ] } | undefined {
74+ const payload = dbChainMockFns . set . mock . calls . at ( - 1 ) ?. [ 0 ] as
75+ | { data ?: { strings ?: string [ ] ; values ?: unknown [ ] } }
76+ | undefined
77+ return payload ?. data
78+ }
79+
6780const EXISTING_ROW = {
6881 id : 'row-1' ,
6982 tableId : 'tbl-1' ,
@@ -109,10 +122,15 @@ describe('updateRow — partial merge', () => {
109122 'req-1'
110123 )
111124
125+ // The returned row is the full merge…
112126 expect ( result . data ) . toEqual ( { name : 'Alice' , age : 31 } )
113- expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
114- expect . objectContaining ( { data : { name : 'Alice' , age : 31 } } )
115- )
127+ // …but the WRITE is a JSONB merge of only the changed cell (`data = data || {age:31}`), so the
128+ // unchanged `name` column is never re-written — that's what keeps concurrent edits to different
129+ // cells of the same row from clobbering each other.
130+ const data = lastSetDataSql ( )
131+ expect ( data ?. strings ?. join ( '' ) ) . toContain ( ' || ' )
132+ expect ( data ?. values ) . toContain ( JSON . stringify ( { age : 31 } ) )
133+ expect ( data ?. values ) . not . toContain ( JSON . stringify ( { name : 'Alice' , age : 31 } ) )
116134 } )
117135
118136 it ( 'allows updating a single column without affecting others' , async ( ) => {
@@ -123,9 +141,7 @@ describe('updateRow — partial merge', () => {
123141 )
124142
125143 expect ( result . data ) . toEqual ( { name : 'Bob' , age : 30 } )
126- expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
127- expect . objectContaining ( { data : { name : 'Bob' , age : 30 } } )
128- )
144+ expect ( lastSetDataSql ( ) ?. values ) . toContain ( JSON . stringify ( { name : 'Bob' } ) )
129145 } )
130146
131147 it ( 'allows explicitly nulling a field while preserving others' , async ( ) => {
@@ -136,9 +152,8 @@ describe('updateRow — partial merge', () => {
136152 )
137153
138154 expect ( result . data ) . toEqual ( { name : 'Alice' , age : null } )
139- expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
140- expect . objectContaining ( { data : { name : 'Alice' , age : null } } )
141- )
155+ // A cleared cell is written as present-with-null, not dropped.
156+ expect ( lastSetDataSql ( ) ?. values ) . toContain ( JSON . stringify ( { age : null } ) )
142157 } )
143158
144159 it ( 'handles a full-row update correctly (idempotent merge)' , async ( ) => {
@@ -151,23 +166,6 @@ describe('updateRow — partial merge', () => {
151166 expect ( result . data ) . toEqual ( { name : 'Bob' , age : 25 } )
152167 } )
153168
154- it ( 'sends only changed keys in JSONB patch mode while returning merged data' , async ( ) => {
155- const result = await updateRow (
156- { tableId : 'tbl-1' , rowId : 'row-1' , data : { age : 31 } , workspaceId : 'ws-1' } ,
157- TABLE ,
158- 'req-1' ,
159- { dataWriteMode : 'patch' }
160- )
161-
162- expect ( result ?. data ) . toEqual ( { name : 'Alice' , age : 31 } )
163- const setPayload = dbChainMockFns . set . mock . calls . at ( - 1 ) ?. [ 0 ] as
164- | { data ?: { strings ?: string [ ] ; values ?: unknown [ ] } }
165- | undefined
166- expect ( setPayload ?. data ?. strings ?. join ( '' ) ) . toContain ( ' || ' )
167- expect ( setPayload ?. data ?. values ) . toContain ( JSON . stringify ( { age : 31 } ) )
168- expect ( setPayload ?. data ?. values ) . not . toContain ( JSON . stringify ( { name : 'Alice' , age : 31 } ) )
169- } )
170-
171169 it ( 'throws when the row does not exist' , async ( ) => {
172170 dbChainMockFns . limit . mockResolvedValueOnce ( [ ] )
173171
@@ -419,3 +417,46 @@ describe('mutation paths — SET LOCAL timeouts', () => {
419417 expect ( findExecutedSqlContaining ( 'hashtextextended' ) ) . toBe ( true )
420418 } )
421419} )
420+
421+ describe ( 'batchUpdateRows — per-row partial merge' , ( ) => {
422+ beforeEach ( ( ) => {
423+ vi . clearAllMocks ( )
424+ resetDbChainMock ( )
425+ } )
426+
427+ it ( 'writes each row as a JSONB merge of only its changed cells' , async ( ) => {
428+ queueTableRows ( userTableRows , [
429+ { id : 'row-1' , data : { name : 'Alice' , age : 30 } } ,
430+ { id : 'row-2' , data : { name : 'Carol' , age : 40 } } ,
431+ ] )
432+ queueTableRows ( tableRowExecutions , [ ] ) // loadExecutionsByRow → no sidecar rows
433+
434+ await batchUpdateRows (
435+ {
436+ tableId : 'tbl-1' ,
437+ workspaceId : 'ws-1' ,
438+ updates : [
439+ { rowId : 'row-1' , data : { age : 31 } } ,
440+ { rowId : 'row-2' , data : { name : 'Dave' } } ,
441+ ] ,
442+ } ,
443+ TABLE ,
444+ 'req-1'
445+ )
446+
447+ // Each row write is `data || {changed}::jsonb`, carrying ONLY that row's changed cell — so a
448+ // batch edit can't clobber a concurrent edit to another cell of the same row.
449+ const writes = dbChainMockFns . set . mock . calls
450+ . map ( ( c ) => c [ 0 ] as { data ?: { strings ?: string [ ] ; values ?: unknown [ ] } } )
451+ . filter ( ( p ) => Array . isArray ( p ?. data ?. strings ) )
452+ expect ( writes . length ) . toBe ( 2 )
453+ for ( const w of writes ) {
454+ expect ( w . data ?. strings ?. join ( '' ) ) . toContain ( ' || ' )
455+ }
456+ const values = writes . flatMap ( ( w ) => w . data ?. values ?? [ ] )
457+ expect ( values ) . toContain ( JSON . stringify ( { age : 31 } ) )
458+ expect ( values ) . toContain ( JSON . stringify ( { name : 'Dave' } ) )
459+ // Never the whole row.
460+ expect ( values ) . not . toContain ( JSON . stringify ( { name : 'Alice' , age : 31 } ) )
461+ } )
462+ } )
0 commit comments