Skip to content

Commit 4930c2c

Browse files
fix(db): encode Date binds in raw sql, document the fetch_types contract
1 parent b57cd58 commit 4930c2c

8 files changed

Lines changed: 133 additions & 27 deletions

File tree

apps/sim/app/api/v1/logs/filters.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ export function buildLogFilters(filters: LogFilters): SQL<unknown> {
3232
const cursorDate = new Date(filters.cursor.startedAt)
3333
if (filters.order === 'desc') {
3434
conditions.push(
35-
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})`
35+
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})`
3636
)
3737
} else {
3838
conditions.push(
39-
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})`
39+
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${sql.param(cursorDate, workflowExecutionLogs.startedAt)}, ${filters.cursor.id})`
4040
)
4141
}
4242
}

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,9 @@ async function pruneStaleReferences(
390390
batchSize: number,
391391
dbClient: LargeValueMetadataClient
392392
): Promise<number> {
393-
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
393+
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
394+
// cleanup job swallows — keep these total rather than relying on the caller.
394395
if (workspaceIds.length === 0) return 0
395-
396396
const rows = await dbClient.execute<{ count: number }>(sql`
397397
WITH deleted AS (
398398
DELETE FROM ${executionLargeValueReferences} AS ref
@@ -434,9 +434,9 @@ async function pruneDeletedParentDependencies(
434434
batchSize: number,
435435
dbClient: LargeValueMetadataClient
436436
): Promise<number> {
437-
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
437+
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
438+
// cleanup job swallows — keep these total rather than relying on the caller.
438439
if (workspaceIds.length === 0) return 0
439-
440440
const rows = await dbClient.execute<{ count: number }>(sql`
441441
WITH deleted AS (
442442
DELETE FROM ${executionLargeValueDependencies} AS dependency
@@ -472,9 +472,9 @@ async function pruneDeletedLargeValueTombstones(
472472
batchSize: number,
473473
dbClient: LargeValueMetadataClient
474474
): Promise<number> {
475-
// `IN ()` is a syntax error; callers chunk a non-empty list, but never rely on that.
475+
// Empty input is a valid no-op, and `IN ()` is a syntax error whose failure the
476+
// cleanup job swallows — keep these total rather than relying on the caller.
476477
if (workspaceIds.length === 0) return 0
477-
478478
const rows = await dbClient.execute<{ count: number }>(sql`
479479
WITH deleted AS (
480480
DELETE FROM ${executionLargeValues} AS value
@@ -483,7 +483,7 @@ async function pruneDeletedLargeValueTombstones(
483483
FROM ${executionLargeValues} AS value
484484
WHERE value.workspace_id IN ${workspaceIds}
485485
AND value.deleted_at IS NOT NULL
486-
AND value.deleted_at < ${deletedBefore}
486+
AND value.deleted_at < ${sql.param(deletedBefore, executionLargeValues.deletedAt)}
487487
AND NOT EXISTS (
488488
SELECT 1
489489
FROM ${executionLargeValueDependencies} AS dependency

apps/sim/lib/execution/payloads/live-paused-statuses-sql.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,13 @@ describe('unreferencedLargeValuePredicate SQL', () => {
3737
// `status = IN (...)` is a syntax error Postgres only reports at execution time.
3838
expect(text).not.toMatch(/=\s*IN\s*\(/)
3939
})
40+
41+
it('never binds an array as a parameter', () => {
42+
// The invariant that matters: the app pools set `fetch_types: false`, so an
43+
// array bound as one parameter fails at execution even though the rendered
44+
// SQL (`ANY($1::text[])`) is valid. Only the params can reveal it.
45+
for (const param of params) {
46+
expect(Array.isArray(param)).toBe(false)
47+
}
48+
})
4049
})

apps/sim/lib/execution/payloads/prune-metadata-sql.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,16 @@ describe('pruneLargeValueMetadata SQL', () => {
6767
}
6868
})
6969

70-
it('never binds an array as a parameter', async () => {
70+
it('binds only scalar parameters', async () => {
7171
const statements = await renderPruneStatements([...ids])
7272

7373
for (const { params } of statements) {
7474
for (const param of params) {
75+
// Arrays fail under fetch_types: false (no serializer, 22P02); Date
76+
// objects fail in postgres-js's unsafe path outright
77+
// (ERR_INVALID_ARG_TYPE) — both must be pre-encoded to strings.
7578
expect(Array.isArray(param)).toBe(false)
79+
expect(param instanceof Date).toBe(false)
7680
}
7781
}
7882
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
// Renders the real key-removal expression against the real drizzle dialect.
6+
// utils.test.ts mocks drizzle wholesale, so its assertions passed against BOTH
7+
// previously-broken forms (`- ${keys}::text[]` and `- ${sql.param(keys)}::text[]`).
8+
// Only a real render can tell them apart, and only the params reveal an array bind:
9+
// the app pools set `fetch_types: false` (packages/db/db.ts), which leaves
10+
// postgres-js unable to serialize an array bound as a single parameter.
11+
import { describe, expect, it, vi } from 'vitest'
12+
13+
vi.unmock('drizzle-orm')
14+
vi.unmock('@sim/db')
15+
vi.unmock('@sim/db/schema')
16+
17+
process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test'
18+
19+
const { sql } = await import('drizzle-orm')
20+
const { PgDialect } = await import('drizzle-orm/pg-core')
21+
22+
/** Mirrors the expression built in `updateWebhookProviderConfig`. */
23+
function renderKeyRemoval(removedKeys: string[]) {
24+
const merged = sql`COALESCE("provider_config"::jsonb, '{}'::jsonb) || ${JSON.stringify({ a: 1 })}::jsonb`
25+
return new PgDialect().sqlToQuery(
26+
sql`(${merged}) - ARRAY[${sql.join(
27+
removedKeys.map((key) => sql`${key}`),
28+
sql`, `
29+
)}]::text[]`
30+
)
31+
}
32+
33+
describe('provider-config key removal SQL', () => {
34+
// Production only ever removes one key (google-drive's pageToken reset), so the
35+
// single-element case is the one that actually runs.
36+
for (const keys of [['pageToken'], ['a', 'b'], ['a', 'b', 'c']]) {
37+
it(`binds ${keys.length} key(s) as scalars inside an ARRAY constructor`, () => {
38+
const { sql: text, params } = renderKeyRemoval(keys)
39+
40+
expect(text).toContain(`ARRAY[${keys.map((_, i) => `$${i + 2}`).join(', ')}]::text[]`)
41+
expect(params).toEqual([JSON.stringify({ a: 1 }), ...keys])
42+
for (const param of params) {
43+
expect(Array.isArray(param)).toBe(false)
44+
}
45+
})
46+
}
47+
48+
it('never renders a row constructor cast to text[]', () => {
49+
// `- ($1, $2)::text[]` — what interpolating the raw JS array produced. Postgres
50+
// rejects it: "cannot cast type record to text[]" (and 22P02 at one element).
51+
const { sql: text } = renderKeyRemoval(['a', 'b'])
52+
expect(text).not.toMatch(/\(\$\d+(, \$\d+)+\)::text\[\]/)
53+
})
54+
})

packages/db/db.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,33 @@ if (!connectionString) {
4343
}
4444

4545
/**
46-
* `fetch_types: false` skips postgres.js's `pg_catalog.pg_type` roundtrip, which
47-
* it otherwise runs on every new connection before that connection's first query.
48-
* It builds array parsers only — scalar types (including `jsonb` and pgvector)
49-
* are unaffected — and Drizzle already parses this schema's `text[]` columns
50-
* itself, so the fetch is pure connection-setup latency.
46+
* `fetch_types: false` skips postgres.js's `pg_catalog.pg_type` roundtrip, which it
47+
* otherwise runs on every new connection before that connection's first query.
5148
*
52-
* The one behavior this changes: a raw `db.execute` that projects an array-typed
53-
* column yields the wire form `'{a,b}'` rather than `['a','b']`, since nothing maps
54-
* the result. Verified by differential test that Drizzle-typed selects and
55-
* `.returning()` are unaffected — `PgArray.mapFromDriverValue` parses the wire form
56-
* itself, and writes never reach the array serializer because Drizzle serializes
57-
* arrays before binding.
49+
* That roundtrip populates the array type map, which postgres.js uses in BOTH
50+
* directions, for every array type — not just `text[]`. Disabling it has two
51+
* consequences on every client built from these options (the primary, the replica,
52+
* and every `dbFor()` sub-pool):
53+
*
54+
* 1. Reads: a raw `db.execute` projecting an array column yields the wire form
55+
* `'{a,b}'`, not `['a','b']`. Drizzle-typed selects and `.returning()` are
56+
* unaffected — `PgArray.mapFromDriverValue` parses the wire form itself.
57+
* 2. Writes: a JS array bound as a SINGLE parameter fails at execution with
58+
* `22P02 Array value must start with "{"` — there is no serializer to build the
59+
* literal, and neither `prepare: false` nor `sql.array()` avoids it. Drizzle-typed
60+
* column writes ARE safe (`PgArray.mapToDriverValue` stringifies first), as are
61+
* `inArray`/`${jsArray}` in a drizzle `sql` template (both expand to scalar binds).
62+
* Raw binds are NOT: never write `sql.param(someArray)`. Pass an expanded list
63+
* (`IN ${ids}`) or an explicit `ARRAY[${sql.join(...)}]::text[]` of scalars.
64+
*
65+
* Scalar types — including `jsonb`, pgvector, enums and ranges — are unaffected.
66+
*
67+
* Note postgres.js's OWN `sql` tag has the opposite semantics: it binds `${array}`
68+
* as one array parameter. `packages/db/scripts/migrate.ts` deliberately omits
69+
* `fetch_types` for that reason; see the note there before sharing these options.
70+
*
71+
* Pinned by apps/sim/lib/execution/payloads/prune-metadata-sql.test.ts, which renders
72+
* the real statements and asserts no bind parameter is an array.
5873
*/
5974
const poolOptions = {
6075
prepare: false,

packages/db/scripts/migrate.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ const hasDirectMigrationUrl = Boolean(process.env.MIGRATION_DATABASE_URL)
4545
* `max_lifetime: null` pins the session for the whole run: the postgres-js
4646
* default recycles the connection after 30–60 min, silently dropping the
4747
* session advisory lock and `SET`s.
48+
*
49+
* Deliberately does NOT set `fetch_types: false`, unlike the app pools in
50+
* `packages/db/db.ts`. Script migrations bind JS arrays directly through
51+
* postgres-js's own `sql` tag (`= ANY(${ids}::text[])`, `unnest(${ids}::text[])`),
52+
* and that binding is powered by the `pg_catalog.pg_type` fetch this option would
53+
* skip. Copying the app's options here for consistency fails every registered
54+
* script migration with `22P02`, aborting the migration container and blocking
55+
* startup on every deploy and every fresh self-hosted install.
4856
*/
4957
const client = postgres(url, {
5058
max: 1,

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,28 @@ 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-
})
28+
// Binds a value as a single parameter. Rejecting arrays here is the only place
29+
// the unit suite can see this bug class: the app pools set `fetch_types: false`
30+
// (packages/db/db.ts), which leaves postgres-js with no array serializer, so an
31+
// array bound as ONE parameter fails at execution with `22P02`. Rendered SQL
32+
// looks perfect (`ANY($1::text[])`), so no assertion on query text can catch it.
33+
sqlFn.param = (value: any, encoder?: any) => {
34+
if (encoder === undefined && Array.isArray(value)) {
35+
throw new Error(
36+
'sql.param(array) binds an array as one parameter, which fails under ' +
37+
'fetch_types: false (packages/db/db.ts). Use `IN ${arr}` or ' +
38+
'ARRAY[${sql.join(arr.map((v) => sql`${v}`), sql`, `)}]::text[].'
39+
)
40+
}
41+
if (encoder === undefined && value instanceof Date) {
42+
throw new Error(
43+
'sql.param(date) without an encoder reaches postgres-js as a Date object, ' +
44+
'which its unsafe path cannot serialize (ERR_INVALID_ARG_TYPE). Bind ' +
45+
'through the matching column: sql.param(date, table.timestampColumn).'
46+
)
47+
}
48+
return { value, toSQL: () => ({ sql: '?', params: [value] }) }
49+
}
3450

3551
return sqlFn
3652
}

0 commit comments

Comments
 (0)