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
20 changes: 17 additions & 3 deletions apps/sim/lib/webhooks/polling/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { mockUpdate, mockSet, mockWhere, sqlCalls } = vi.hoisted(() => ({
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockWhere: vi.fn(),
sqlCalls: [] as Array<{ values: unknown[] }>,
sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>,
}))

vi.mock('@sim/db', () => ({ db: { update: mockUpdate } }))
Expand All @@ -23,8 +23,8 @@ vi.mock('@sim/db/schema', () => ({
workflowDeploymentVersion: {},
}))
vi.mock('drizzle-orm', () => ({
sql: (_strings: readonly string[], ...values: unknown[]) => {
const node = { values }
sql: (strings: readonly string[], ...values: unknown[]) => {
const node = { strings, values }
sqlCalls.push(node)
return node
},
Expand All @@ -50,6 +50,10 @@ function allInterpolatedValues(): unknown[] {
return sqlCalls.flatMap((c) => c.values)
}

function allSqlText(): string {
return sqlCalls.map((c) => c.strings.join('')).join(' ')
}

describe('updateWebhookProviderConfig (atomic jsonb merge)', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -77,4 +81,14 @@ describe('updateWebhookProviderConfig (atomic jsonb merge)', () => {
expect(allInterpolatedValues()).toContain(JSON.stringify({ historyId: 'h1' }))
expect(allInterpolatedValues().some((v) => Array.isArray(v))).toBe(false)
})

it('casts the json column to jsonb for the merge and back to json for storage', async () => {
await updateWebhookProviderConfig('wh-1', { historyId: 'h1', cleared: undefined }, logger)

const sqlText = allSqlText()
// Column (interpolated as a value) is cast to jsonb: `COALESCE(<col>::jsonb, ...)`
expect(sqlText).toContain('COALESCE(::jsonb')
// Merge runs in jsonb space, result cast back to the json column: `(<expr>)::json`
expect(sqlText).toContain(')::json')
})
})
12 changes: 9 additions & 3 deletions apps/sim/lib/webhooks/polling/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,13 @@ export async function runWithConcurrency(
}

/**
* Read-merge-write pattern for updating provider-specific config fields.
* Atomically merge provider-specific config fields into `webhook.provider_config`.
* Each provider passes its specific state updates (historyId, lastSeenGuids, etc.).
*
* The column is `json` (not `jsonb`), which has no merge operators, so the existing
* value is cast to `jsonb` for the `||`/`-` merge and the result cast back to `json`
* for storage. Casting is required — a bare `jsonb` expression cannot be assigned to
* the `json` column.
*/
export async function updateWebhookProviderConfig(
webhookId: string,
Expand All @@ -164,12 +169,13 @@ export async function updateWebhookProviderConfig(
else defined[key] = value
}

const merged = sql`COALESCE(${webhook.providerConfig}, '{}'::jsonb) || ${JSON.stringify(defined)}::jsonb`
const merged = sql`COALESCE(${webhook.providerConfig}::jsonb, '{}'::jsonb) || ${JSON.stringify(defined)}::jsonb`
const nextConfig = removedKeys.length > 0 ? sql`(${merged}) - ${removedKeys}::text[]` : merged

await db
.update(webhook)
.set({
providerConfig: removedKeys.length > 0 ? sql`(${merged}) - ${removedKeys}::text[]` : merged,
providerConfig: sql`(${nextConfig})::json`,
updatedAt: new Date(),
})
.where(eq(webhook.id, webhookId))
Expand Down
Loading