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
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ jobs:
- name: Tool request transport boundary audit
run: bun run check:tool-request-boundary

- name: SQL Date binding audit
run: bun run check:sql-date-binding

- name: Verify generated tool metadata is in sync
run: bun run tool-metadata:check

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/schedules/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
createMockSql,
dbChainMock,
dbChainMockFns,
requestUtilsMockFns,
Expand Down Expand Up @@ -102,7 +103,7 @@ vi.mock('drizzle-orm', () => ({
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
asc: vi.fn((field: unknown) => ({ type: 'asc', field })),
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })),
sql: createMockSql(),
}))

vi.mock('@sim/db', () => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/schedules/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ function staleScheduleExecutionJobsFilter(now: Date) {
THEN (${asyncJobs.payload} ->> 'executionTimeoutMs')::double precision / 1000 + ${cleanupGraceSeconds}
ELSE ${legacyMaxDurationSeconds}
END
) * interval '1 second' <= ${now}`
) * interval '1 second' <= ${sql.param(now, asyncJobs.startedAt)}`
)
)
}
Expand Down
28 changes: 27 additions & 1 deletion apps/sim/lib/data-drains/sources/cursor.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/**
* @vitest-environment node
*/
import type { PgColumn } from 'drizzle-orm/pg-core'
import { describe, expect, it } from 'vitest'
import { decodeTimeCursor, encodeTimeCursor } from '@/lib/data-drains/sources/cursor'
import {
decodeTimeCursor,
encodeTimeCursor,
timeCursorPredicate,
} from '@/lib/data-drains/sources/cursor'

describe('time cursor encoding', () => {
it('round-trips a valid cursor', () => {
Expand All @@ -24,3 +29,24 @@ describe('time cursor encoding', () => {
expect(decodeTimeCursor(JSON.stringify({}))).toBeNull()
})
})

describe('timeCursorPredicate', () => {
const timestampCol = { name: 'created_at' } as unknown as PgColumn
const idCol = { name: 'id' } as unknown as PgColumn

it('returns undefined without a cursor', () => {
expect(timeCursorPredicate(timestampCol, idCol, null)).toBeUndefined()
})

it('binds the cursor timestamp through the column encoder', () => {
const predicate = timeCursorPredicate(timestampCol, idCol, {
ts: '2026-01-01T00:00:00.000Z',
id: 'row-1',
}) as unknown as { values: unknown[] }

expect(predicate.values).not.toContainEqual(new Date('2026-01-01T00:00:00.000Z'))
expect(predicate.values).toContainEqual(
expect.objectContaining({ value: new Date('2026-01-01T00:00:00.000Z') })
)
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/data-drains/sources/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function timeCursorPredicate(
cursor: TimeCursor | null
): SQL | undefined {
if (!cursor) return undefined
return sql`(date_trunc('milliseconds', ${timestampCol}), ${idCol}) > (${new Date(cursor.ts)}, ${cursor.id})`
return sql`(date_trunc('milliseconds', ${timestampCol}), ${idCol}) > (${sql.param(new Date(cursor.ts), timestampCol)}, ${cursor.id})`
}

/**
Expand Down
13 changes: 10 additions & 3 deletions apps/sim/lib/execution/remote-sandbox/image-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* guard down, plus the failure modes that must leave the retention sweep a job to
* finish rather than losing the image silently.
*/
import { createMockSql } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
Expand Down Expand Up @@ -88,7 +89,7 @@ vi.mock('drizzle-orm', () => ({
lt: (...args: unknown[]) => args,
notInArray: (...args: unknown[]) => args,
or: (...args: unknown[]) => args,
sql: (...args: unknown[]) => args,
sql: createMockSql(),
}))

vi.mock('@/lib/execution/remote-sandbox/provider', () => ({
Expand Down Expand Up @@ -977,10 +978,16 @@ describe('cleanupSandboxImages', () => {
})
})

/** True when any leaf of the mocked predicate tree is a `Date`, i.e. a time bound. */
/**
* True when any leaf of the mocked predicate tree is a `Date`, i.e. a time bound.
* Cutoffs are bound through `sql.param(date, column)`, so the walk descends into
* the mock's fragment and param objects as well as condition arrays.
*/
function hasTimeBound(predicate: unknown): boolean {
if (predicate instanceof Date) return true
return Array.isArray(predicate) && predicate.some(hasTimeBound)
if (Array.isArray(predicate)) return predicate.some(hasTimeBound)
if (predicate && typeof predicate === 'object') return Object.values(predicate).some(hasTimeBound)
return false
}

/**
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/lib/execution/remote-sandbox/image-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,7 @@ export async function cleanupSandboxImages(retentionDays: number): Promise<{
const images = provider.images

const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
const beyondRetention = sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${sql.param(cutoff, sandboxImage.lastUsedAt)}`
const stale = await db
.select({
id: sandboxImage.id,
Expand All @@ -1015,7 +1016,7 @@ export async function cleanupSandboxImages(retentionDays: number): Promise<{
.where(
and(
eq(sandboxImage.provider, provider.id),
sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${cutoff}`,
beyondRetention,
sql`not exists (select 1 from workspace_sandbox ws where ws.spec_hash = ${sandboxImage.specHash})`
)
)
Expand All @@ -1033,11 +1034,7 @@ export async function cleanupSandboxImages(retentionDays: number): Promise<{
for (let offset = 0; offset < stale.length; offset += CLEANUP_CONCURRENCY) {
const chunk = stale.slice(offset, offset + CLEANUP_CONCURRENCY)
const outcomes = await Promise.all(
chunk.map((row) =>
claimAndDeleteImage(provider.id, images, row.specHash, [
sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${cutoff}`,
])
)
chunk.map((row) => claimAndDeleteImage(provider.id, images, row.specHash, [beyondRetention]))
)

deleted += outcomes.filter((outcome) => outcome === 'released').length
Expand Down
48 changes: 48 additions & 0 deletions apps/sim/lib/workspace-events/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockOnConflictDoUpdate, mockReturning } = vi.hoisted(() => ({
mockOnConflictDoUpdate: vi.fn(),
mockReturning: vi.fn(),
}))

vi.mock('@sim/db', () => ({
db: {
insert: () => ({
values: () => ({ onConflictDoUpdate: mockOnConflictDoUpdate }),
}),
},
}))

import { claimCooldown } from '@/lib/workspace-events/state'

describe('claimCooldown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOnConflictDoUpdate.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([{ workflowId: 'workflow-1' }])
})

it('claims the slot when the upsert returns a row', async () => {
await expect(claimCooldown('workflow-1', 'block-1', 'scope-1', 60_000)).resolves.toBe(true)
})

it('declines the slot when the cooldown predicate matched nothing', async () => {
mockReturning.mockResolvedValue([])
await expect(claimCooldown('workflow-1', 'block-1', 'scope-1', 60_000)).resolves.toBe(false)
})

it('binds the cooldown threshold through the column encoder', async () => {
await claimCooldown('workflow-1', 'block-1', 'scope-1', 60_000)

const { setWhere } = mockOnConflictDoUpdate.mock.calls[0][0]
expect(setWhere.values.some((value: unknown) => value instanceof Date)).toBe(false)
expect(
setWhere.values.some(
(value: unknown) => (value as { value?: unknown } | null)?.value instanceof Date
)
).toBe(true)
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/workspace-events/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function claimCooldown(
.onConflictDoUpdate({
target: [simTriggerState.workflowId, simTriggerState.blockId, simTriggerState.scopeKey],
set: { lastFiredAt: now, updatedAt: now },
setWhere: sql`${simTriggerState.lastFiredAt} IS NULL OR ${simTriggerState.lastFiredAt} < ${threshold}`,
setWhere: sql`${simTriggerState.lastFiredAt} IS NULL OR ${simTriggerState.lastFiredAt} < ${sql.param(threshold, simTriggerState.lastFiredAt)}`,
})
.returning({ workflowId: simTriggerState.workflowId })

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts",
"check:tool-request-boundary": "bun test scripts/check-tool-request-boundary.test.ts && bun run scripts/check-tool-request-boundary.ts",
"check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts",
"check:sql-date-binding": "bun test scripts/check-sql-date-binding.test.ts && bun run scripts/check-sql-date-binding.ts",
"check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts",
"check:react-query": "bun run scripts/check-react-query-patterns.ts --check",
"check:client-boundary": "bun run scripts/check-client-boundary-imports.ts --check",
Expand Down
25 changes: 16 additions & 9 deletions packages/testing/src/mocks/database.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import { vi } from 'vitest'
/**
* Creates mock SQL template literal function.
* Mimics drizzle-orm's sql tagged template.
*
* The `Date` guards below are a best-effort backstop, not the gate: tests that
* override the `drizzle-orm` mock bypass them entirely. `bun run check:sql-date-binding`
* is the repo-wide authority. `drizzle()` overwrites postgres-js's temporal
* serializers (OIDs 1082/1083/1114/1184/1182/1185/1115/1231) with an identity
* function because drizzle maps timestamps itself through the column's
* `mapToDriverValue`. Outside column context that mapping never runs, so the Date
* reaches the wire encoder unserialized. The pools' `prepare` / `fetch_types`
* options are irrelevant to this failure.
*/
export function createMockSql() {
const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => {
// Same hazard as `sql.param(date)` below, and the form that actually shipped:
// an interpolated `Date` carries no column context, so drizzle skips
// `PgTimestamp.mapToDriverValue` and postgres-js receives a Date it cannot serialize.
if (values.some((value) => value instanceof Date)) {
throw new Error(
'sql`…${date}` interpolates a Date without an encoder, which reaches ' +
'postgres-js as a Date object its unsafe path cannot serialize. Bind ' +
'through the matching column: sql.param(date, table.timestampColumn).'
'sql`…${date}` interpolates a Date without an encoder, so drizzle never runs ' +
'the column mapping and postgres-js receives an unserialized Date ' +
'(ERR_INVALID_ARG_TYPE). Bind through the matching column: ' +
'sql.param(date, table.timestampColumn).'
)
}
const fragment = {
Expand Down Expand Up @@ -56,9 +63,9 @@ export function createMockSql() {
}
if (encoder === undefined && value instanceof Date) {
throw new Error(
'sql.param(date) without an encoder reaches postgres-js as a Date object, ' +
'which its unsafe path cannot serialize (ERR_INVALID_ARG_TYPE). Bind ' +
'through the matching column: sql.param(date, table.timestampColumn).'
'sql.param(date) without an encoder skips the column mapping and reaches ' +
'postgres-js as an unserialized Date (ERR_INVALID_ARG_TYPE). Bind through ' +
'the matching column: sql.param(date, table.timestampColumn).'
)
}
return { value, toSQL: () => ({ sql: '?', params: [value] }) }
Expand Down
62 changes: 62 additions & 0 deletions scripts/check-sql-date-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test'
import { findSqlDateBindingViolations } from './check-sql-date-binding'

describe('sql Date binding audit', () => {
test('rejects every unbound Date form that reaches a raw template', () => {
const violations = findSqlDateBindingViolations(`
const now = new Date()
const threshold = new Date(now.getTime() - 1000)
const alias = threshold
function scan(cutoff: Date, since: Date | null) {
const inline = sql\`col < \${new Date(cursor.ts)}\`
const local = sql\`col < \${now}\`
const chained = sql\`col < \${alias}\`
const generic = sql<boolean>\`col < \${threshold}\`
const annotated = sql\`col < \${cutoff}\`
const nullable = sql\`col < \${since}\`
const fallback = sql\`col < \${since ?? now}\`
const unencoded = sql.param(now)
}
`)

expect(violations.map((violation) => violation.expression)).toEqual([
'new Date(cursor.ts)',
'now',
'alias',
'threshold',
'cutoff',
'since',
'since ?? now',
'now',
])
})

test('accepts column-bound params, non-Date values, and annotated exceptions', () => {
expect(
findSqlDateBindingViolations(`
const now = new Date()
const bound = sql\`col < \${sql.param(now, asyncJobs.startedAt)}\`
const fragment = sql\`col < \${sql.param(new Date(), table.createdAt)}\`
const columns = sql\`\${table.startedAt} < \${table.endedAt}\`
const scalars = sql\`col < \${MAX_INT32} AND name = \${name}\`
const notSql = other\`col < \${now}\`
// sql-date-bound: raw text column, no timestamp encoding applies
const excused = sql\`col < \${now}\`
`)
).toEqual([])
})

test('rejects annotation markers that are malformed or incidental', () => {
const violations = findSqlDateBindingViolations(`
const now = new Date()
// sql-date-bound:
const bareMarker = sql\`col < \${now}\`
const label = 'sql-date-bound: not a comment'
const incidental = sql\`col < \${now}\`
// trailing marker sql-date-bound: reason
const misplaced = sql\`col < \${now}\`
`)

expect(violations.map((violation) => violation.expression)).toEqual(['now', 'now', 'now'])
})
})
Loading