Skip to content

Commit 3e03f8c

Browse files
authored
fix(webhooks): cast json provider_config for atomic jsonb merge (#5249)
updateWebhookProviderConfig built a DB-side merge with jsonb operators (COALESCE(provider_config, '{}'::jsonb) || $1::jsonb), but the provider_config column is json, not jsonb. Postgres cannot apply jsonb merge operators to a json column, so every polling state write failed with "could not convert type jsonb to json" — silently breaking historyId/lastCheckedTimestamp/pageToken/lastSeenGuids persistence for all polling webhooks (Gmail, RSS, Google Sheets/Drive, Outlook, IMAP) since the atomic-merge change landed. Cast the column to jsonb for the || / - merge and cast the result back to json for storage, matching the existing pattern in subscription.ts.
1 parent 7a2103e commit 3e03f8c

2 files changed

Lines changed: 26 additions & 6 deletions

File tree

apps/sim/lib/webhooks/polling/utils.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { mockUpdate, mockSet, mockWhere, sqlCalls } = vi.hoisted(() => ({
77
mockUpdate: vi.fn(),
88
mockSet: vi.fn(),
99
mockWhere: vi.fn(),
10-
sqlCalls: [] as Array<{ values: unknown[] }>,
10+
sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>,
1111
}))
1212

1313
vi.mock('@sim/db', () => ({ db: { update: mockUpdate } }))
@@ -23,8 +23,8 @@ vi.mock('@sim/db/schema', () => ({
2323
workflowDeploymentVersion: {},
2424
}))
2525
vi.mock('drizzle-orm', () => ({
26-
sql: (_strings: readonly string[], ...values: unknown[]) => {
27-
const node = { values }
26+
sql: (strings: readonly string[], ...values: unknown[]) => {
27+
const node = { strings, values }
2828
sqlCalls.push(node)
2929
return node
3030
},
@@ -50,6 +50,10 @@ function allInterpolatedValues(): unknown[] {
5050
return sqlCalls.flatMap((c) => c.values)
5151
}
5252

53+
function allSqlText(): string {
54+
return sqlCalls.map((c) => c.strings.join('')).join(' ')
55+
}
56+
5357
describe('updateWebhookProviderConfig (atomic jsonb merge)', () => {
5458
beforeEach(() => {
5559
vi.clearAllMocks()
@@ -77,4 +81,14 @@ describe('updateWebhookProviderConfig (atomic jsonb merge)', () => {
7781
expect(allInterpolatedValues()).toContain(JSON.stringify({ historyId: 'h1' }))
7882
expect(allInterpolatedValues().some((v) => Array.isArray(v))).toBe(false)
7983
})
84+
85+
it('casts the json column to jsonb for the merge and back to json for storage', async () => {
86+
await updateWebhookProviderConfig('wh-1', { historyId: 'h1', cleared: undefined }, logger)
87+
88+
const sqlText = allSqlText()
89+
// Column (interpolated as a value) is cast to jsonb: `COALESCE(<col>::jsonb, ...)`
90+
expect(sqlText).toContain('COALESCE(::jsonb')
91+
// Merge runs in jsonb space, result cast back to the json column: `(<expr>)::json`
92+
expect(sqlText).toContain(')::json')
93+
})
8094
})

apps/sim/lib/webhooks/polling/utils.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,13 @@ export async function runWithConcurrency(
148148
}
149149

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

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

169175
await db
170176
.update(webhook)
171177
.set({
172-
providerConfig: removedKeys.length > 0 ? sql`(${merged}) - ${removedKeys}::text[]` : merged,
178+
providerConfig: sql`(${nextConfig})::json`,
173179
updatedAt: new Date(),
174180
})
175181
.where(eq(webhook.id, webhookId))

0 commit comments

Comments
 (0)