Skip to content

Commit 26dc6fc

Browse files
committed
fix(realtime): compare search replacements independent of object key order
1 parent 3bdc279 commit 26dc6fc

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/** @vitest-environment node */
2+
import { OPERATION_TARGETS, SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const { mockTransaction, mockSelectWhere, mockSet } = vi.hoisted(() => ({
6+
mockTransaction: vi.fn(),
7+
mockSelectWhere: vi.fn(),
8+
mockSet: vi.fn(),
9+
}))
10+
11+
vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() }))
12+
vi.mock('@sim/db', () => ({
13+
instrumentPoolClient: vi.fn(),
14+
resolveDbUrl: vi.fn(() => 'postgres://localhost/test'),
15+
workflow: { id: 'workflow.id' },
16+
workflowBlocks: { id: 'block.id', workflowId: 'block.workflowId' },
17+
workflowEdges: {},
18+
workflowSubflows: {},
19+
}))
20+
vi.mock('@sim/db/timestamps', () => ({ withUtcTimestamps: (options: unknown) => options }))
21+
vi.mock('@sim/logger', () => ({
22+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
23+
}))
24+
vi.mock('@sim/platform-authz/workflow', () => ({
25+
getActiveWorkflowContext: vi.fn().mockResolvedValue({ id: 'workflow-1' }),
26+
}))
27+
vi.mock('@sim/workflow-persistence/load', () => ({
28+
loadWorkflowFromNormalizedTablesRaw: vi.fn(),
29+
}))
30+
vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubBlockValues: vi.fn() }))
31+
vi.mock('drizzle-orm', () => ({
32+
and: vi.fn(),
33+
eq: vi.fn(),
34+
inArray: vi.fn(),
35+
isNull: vi.fn(),
36+
or: vi.fn(),
37+
sql: vi.fn(),
38+
}))
39+
vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({ transaction: mockTransaction }) }))
40+
vi.mock('postgres', () => ({ default: vi.fn() }))
41+
vi.mock('@/env', () => ({
42+
env: { DATABASE_URL: 'postgres://localhost/test' },
43+
}))
44+
45+
import { persistWorkflowOperation } from '@/database/operations'
46+
47+
const transaction = {
48+
select: () => ({ from: () => ({ where: mockSelectWhere }) }),
49+
update: () => ({ set: mockSet }),
50+
delete: vi.fn(),
51+
insert: vi.fn(),
52+
}
53+
54+
describe('search replacement persistence', () => {
55+
const expected = [
56+
{
57+
type: 'function',
58+
params: { language: 'javascript', code: 'return 1' },
59+
usageControl: 'none',
60+
},
61+
]
62+
const replacement = [{ ...expected[0], params: { ...expected[0].params, code: 'return 2' } }]
63+
64+
beforeEach(() => {
65+
vi.clearAllMocks()
66+
mockTransaction.mockImplementation(
67+
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
68+
)
69+
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
70+
})
71+
72+
function replaceTools(stored: unknown, expectedValue: unknown = expected) {
73+
mockSelectWhere.mockResolvedValue([
74+
{
75+
id: 'agent-1',
76+
type: 'agent',
77+
locked: false,
78+
data: {},
79+
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: stored } },
80+
},
81+
])
82+
return persistWorkflowOperation('workflow-1', {
83+
operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE,
84+
target: OPERATION_TARGETS.SUBBLOCK,
85+
timestamp: Date.now(),
86+
payload: {
87+
updates: [{ blockId: 'agent-1', subblockId: 'tools', value: replacement, expectedValue }],
88+
},
89+
})
90+
}
91+
92+
it('accepts equivalent nested tool objects after JSONB changes their key order', async () => {
93+
const stored = [
94+
{
95+
usageControl: 'none',
96+
params: { code: 'return 1', language: 'javascript' },
97+
type: 'function',
98+
},
99+
]
100+
101+
await expect(replaceTools(stored)).resolves.toBeUndefined()
102+
expect(mockSet).toHaveBeenLastCalledWith(
103+
expect.objectContaining({
104+
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: replacement } },
105+
})
106+
)
107+
})
108+
109+
it('still rejects a tool parameter changed by another editor', async () => {
110+
await expect(
111+
replaceTools([{ ...expected[0], params: { ...expected[0].params, code: 'return 3' } }])
112+
).rejects.toThrow('changed since replacement was planned')
113+
expect(mockSet).toHaveBeenCalledTimes(1)
114+
})
115+
116+
it('still rejects reordered tool arrays', async () => {
117+
const another = { ...expected[0], params: { ...expected[0].params, code: 'return 3' } }
118+
await expect(replaceTools([another, expected[0]], [expected[0], another])).rejects.toThrow(
119+
'changed since replacement was planned'
120+
)
121+
expect(mockSet).toHaveBeenCalledTimes(1)
122+
})
123+
})

apps/realtime/src/database/operations.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDeepStrictEqual } from 'node:util'
12
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
23
import * as schema from '@sim/db'
34
import {
@@ -1988,10 +1989,6 @@ async function handleSubflowOperationTx(
19881989
}
19891990
}
19901991

1991-
function valuesEqual(left: unknown, right: unknown): boolean {
1992-
return JSON.stringify(left) === JSON.stringify(right)
1993-
}
1994-
19951992
// Subblock operations - targeted value updates without replacing workflow state
19961993
async function handleSubblockOperationTx(
19971994
tx: any,
@@ -2039,7 +2036,8 @@ async function handleSubblockOperationTx(
20392036
const subBlocks = { ...((block.subBlocks as Record<string, any>) || {}) }
20402037
const currentSubBlock = subBlocks[subblockId]
20412038
const currentValue = currentSubBlock?.value
2042-
if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) {
2039+
/** JSONB can reorder object keys; changed values and array order must still conflict. */
2040+
if (expectedValue !== undefined && !isDeepStrictEqual(currentValue, expectedValue)) {
20432041
throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`)
20442042
}
20452043

0 commit comments

Comments
 (0)