Skip to content

Commit cf599d0

Browse files
fix(cleanup): bind array params as arrays, not expanded value lists
1 parent ec3156f commit cf599d0

5 files changed

Lines changed: 89 additions & 13 deletions

File tree

apps/sim/lib/execution/payloads/large-value-metadata.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ async function pruneStaleReferences(
396396
WHERE ref.ctid IN (
397397
SELECT ref.ctid
398398
FROM ${executionLargeValueReferences} AS ref
399-
WHERE ref.workspace_id = ANY(${workspaceIds}::text[])
399+
WHERE ref.workspace_id = ANY(${sql.param(workspaceIds)}::text[])
400400
AND (
401401
(
402402
ref.source = 'execution_log'
@@ -437,7 +437,7 @@ async function pruneDeletedParentDependencies(
437437
WHERE dependency.ctid IN (
438438
SELECT dependency.ctid
439439
FROM ${executionLargeValueDependencies} AS dependency
440-
WHERE dependency.workspace_id = ANY(${workspaceIds}::text[])
440+
WHERE dependency.workspace_id = ANY(${sql.param(workspaceIds)}::text[])
441441
AND (
442442
EXISTS (
443443
SELECT 1
@@ -472,7 +472,7 @@ async function pruneDeletedLargeValueTombstones(
472472
WHERE value.ctid IN (
473473
SELECT value.ctid
474474
FROM ${executionLargeValues} AS value
475-
WHERE value.workspace_id = ANY(${workspaceIds}::text[])
475+
WHERE value.workspace_id = ANY(${sql.param(workspaceIds)}::text[])
476476
AND value.deleted_at IS NOT NULL
477477
AND value.deleted_at < ${deletedBefore}
478478
AND NOT EXISTS (
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
// Renders the real prune statements against the real drizzle dialect and
6+
// schema. These are raw `sql` templates, so a rendering bug only surfaces at
7+
// execution time — the global drizzle/schema mocks would hide it entirely.
8+
import { describe, expect, it, vi } from 'vitest'
9+
10+
vi.unmock('drizzle-orm')
11+
vi.unmock('@sim/db')
12+
vi.unmock('@sim/db/schema')
13+
14+
process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test'
15+
16+
const { PgDialect } = await import('drizzle-orm/pg-core')
17+
const { pruneLargeValueMetadata } = await import('@/lib/execution/payloads/large-value-metadata')
18+
19+
/** Captures the raw statements `pruneLargeValueMetadata` issues, without a database. */
20+
async function renderPruneStatements(workspaceIds: string[]): Promise<string[]> {
21+
const dialect = new PgDialect()
22+
const rendered: string[] = []
23+
const capturingClient = {
24+
execute: async (query: Parameters<PgDialect['sqlToQuery']>[0]) => {
25+
rendered.push(dialect.sqlToQuery(query).sql)
26+
return [{ count: 0 }]
27+
},
28+
}
29+
30+
await pruneLargeValueMetadata({
31+
workspaceIds,
32+
tombstonesDeletedBefore: new Date('2026-01-01T00:00:00Z'),
33+
dbClient: capturingClient as unknown as Parameters<
34+
typeof pruneLargeValueMetadata
35+
>[0]['dbClient'],
36+
})
37+
38+
return rendered
39+
}
40+
41+
describe('pruneLargeValueMetadata SQL', () => {
42+
it('binds workspace ids as one array parameter, not a value list', async () => {
43+
const statements = await renderPruneStatements(['ws-1', 'ws-2'])
44+
45+
expect(statements.length).toBeGreaterThan(0)
46+
for (const statement of statements) {
47+
// `ANY(($1, $2)::text[])` is what interpolating the raw JS array yields —
48+
// Postgres rejects it ("cannot cast type record to text[]"), and with a
49+
// single id `ANY(($1)::text[])` fails as 22P02 instead.
50+
expect(statement).not.toMatch(/ANY\(\(\$\d+/)
51+
expect(statement).toMatch(/ANY\(\$\d+::text\[\]\)/)
52+
}
53+
})
54+
55+
it('renders identically for a single workspace id', async () => {
56+
const statements = await renderPruneStatements(['ws-only'])
57+
58+
expect(statements.length).toBeGreaterThan(0)
59+
for (const statement of statements) {
60+
expect(statement).toMatch(/ANY\(\$\d+::text\[\]\)/)
61+
}
62+
})
63+
})

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,23 @@ const { sqlCalls } = vi.hoisted(() => ({
99
sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>,
1010
}))
1111

12-
vi.mock('drizzle-orm', () => ({
13-
sql: (strings: readonly string[], ...values: unknown[]) => {
12+
vi.mock('drizzle-orm', () => {
13+
const sql = (strings: readonly string[], ...values: unknown[]) => {
1414
const node = { strings, values }
1515
sqlCalls.push(node)
1616
return node
17-
},
18-
and: vi.fn(),
19-
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
20-
isNull: vi.fn(),
21-
ne: vi.fn(),
22-
or: vi.fn(),
23-
}))
17+
}
18+
// Identity, so a `sql.param(arr)` value still shows up verbatim in `values`.
19+
sql.param = (value: unknown) => value
20+
return {
21+
sql,
22+
and: vi.fn(),
23+
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
24+
isNull: vi.fn(),
25+
ne: vi.fn(),
26+
or: vi.fn(),
27+
}
28+
})
2429
vi.mock('@/app/api/auth/oauth/utils', () => ({
2530
getOAuthToken: vi.fn(),
2631
refreshAccessTokenIfNeeded: vi.fn(),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ export async function updateWebhookProviderConfig(
164164
}
165165

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

169170
await db
170171
.update(webhook)

packages/testing/src/mocks/database.mock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export function createMockSql() {
2525
}),
2626
})
2727

28+
// Binds a value as a single parameter — drizzle's escape hatch for passing an
29+
// array to Postgres as an array rather than expanding it into a value list.
30+
sqlFn.param = (value: any) => ({
31+
value,
32+
toSQL: () => ({ sql: '?', params: [value] }),
33+
})
34+
2835
return sqlFn
2936
}
3037

0 commit comments

Comments
 (0)