diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 4ae17ec7ce0..610296d6dba 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -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 diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 935a957f7df..80422e2661f 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + createMockSql, dbChainMock, dbChainMockFns, requestUtilsMockFns, @@ -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', () => ({ diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 7581eafc134..b845e3bcf34 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -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)}` ) ) } diff --git a/apps/sim/lib/data-drains/sources/cursor.test.ts b/apps/sim/lib/data-drains/sources/cursor.test.ts index d0389fa2209..341739981f2 100644 --- a/apps/sim/lib/data-drains/sources/cursor.test.ts +++ b/apps/sim/lib/data-drains/sources/cursor.test.ts @@ -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', () => { @@ -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') }) + ) + }) +}) diff --git a/apps/sim/lib/data-drains/sources/cursor.ts b/apps/sim/lib/data-drains/sources/cursor.ts index b133a6449d0..9d8dd709a74 100644 --- a/apps/sim/lib/data-drains/sources/cursor.ts +++ b/apps/sim/lib/data-drains/sources/cursor.ts @@ -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})` } /** diff --git a/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts b/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts index 92e625663dc..250daf087ef 100644 --- a/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts @@ -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 { @@ -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', () => ({ @@ -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 } /** diff --git a/apps/sim/lib/execution/remote-sandbox/image-registry.ts b/apps/sim/lib/execution/remote-sandbox/image-registry.ts index a36f95dc7ff..9fabd8f0150 100644 --- a/apps/sim/lib/execution/remote-sandbox/image-registry.ts +++ b/apps/sim/lib/execution/remote-sandbox/image-registry.ts @@ -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, @@ -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})` ) ) @@ -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 diff --git a/apps/sim/lib/workspace-events/state.test.ts b/apps/sim/lib/workspace-events/state.test.ts new file mode 100644 index 00000000000..86fef123171 --- /dev/null +++ b/apps/sim/lib/workspace-events/state.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/workspace-events/state.ts b/apps/sim/lib/workspace-events/state.ts index 39c3c9ca2a0..ca06d5d0121 100644 --- a/apps/sim/lib/workspace-events/state.ts +++ b/apps/sim/lib/workspace-events/state.ts @@ -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 }) diff --git a/package.json b/package.json index f3de8d278db..e36f32afc48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index d246ba9d603..61d152233b7 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -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 = { @@ -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] }) } diff --git a/scripts/check-sql-date-binding.test.ts b/scripts/check-sql-date-binding.test.ts new file mode 100644 index 00000000000..834aed257fd --- /dev/null +++ b/scripts/check-sql-date-binding.test.ts @@ -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\`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']) + }) +}) diff --git a/scripts/check-sql-date-binding.ts b/scripts/check-sql-date-binding.ts new file mode 100644 index 00000000000..a85a619b1f5 --- /dev/null +++ b/scripts/check-sql-date-binding.ts @@ -0,0 +1,267 @@ +#!/usr/bin/env bun +/** + * Fails when a `Date` reaches a raw drizzle `sql` template without a column encoder. + * + * `drizzle()` overwrites postgres-js's temporal serializers (OIDs 1082/1083/1114/1184/ + * 1182/1185/1115/1231) with an identity function, because drizzle normally maps timestamps + * itself through the column's `mapToDriverValue`. A raw `sql` template carries no column + * context, so an interpolated `Date` skips that mapping, reaches the now-identity serializer + * unchanged, and the wire encoder throws `ERR_INVALID_ARG_TYPE`. Binding through + * `sql.param(date, table.column)` restores the column mapping. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const SCAN_DIRS = [join(ROOT, 'apps'), join(ROOT, 'packages')] +const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build', 'out']) +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']) +const ALLOW_ANNOTATION = '// sql-date-bound:' + +interface Violation { + file: string + line: number + expression: string + reason: string +} + +interface SyntaxNode extends Record { + type: string + start?: number | null + end?: number | null + loc?: { start: { line: number } } | null +} + +function isSyntaxNode(value: unknown): value is SyntaxNode { + return ( + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + ) +} + +function getChildNodes(node: SyntaxNode): SyntaxNode[] { + const children: SyntaxNode[] = [] + for (const value of Object.values(node)) { + if (isSyntaxNode(value)) children.push(value) + else if (Array.isArray(value)) + for (const item of value) if (isSyntaxNode(item)) children.push(item) + } + return children +} + +function unwrap(node: SyntaxNode): SyntaxNode { + let current = node + while ( + (current.type === 'TSAsExpression' || + current.type === 'TSNonNullExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'ParenthesizedExpression') && + isSyntaxNode(current.expression) + ) { + current = current.expression + } + return current +} + +function isDateAnnotation(annotation: unknown): boolean { + if (!isSyntaxNode(annotation)) return false + if (annotation.type === 'TSTypeAnnotation') return isDateAnnotation(annotation.typeAnnotation) + if (annotation.type === 'TSUnionType' && Array.isArray(annotation.types)) + return annotation.types.some(isDateAnnotation) + return ( + annotation.type === 'TSTypeReference' && + isSyntaxNode(annotation.typeName) && + annotation.typeName.name === 'Date' + ) +} + +/** `new Date(...)` plus the expression forms that trivially forward one. */ +function isDateExpression(node: unknown, dateNames: ReadonlySet): boolean { + if (!isSyntaxNode(node)) return false + const current = unwrap(node) + if (current.type === 'NewExpression') + return isSyntaxNode(current.callee) && current.callee.name === 'Date' + if (current.type === 'Identifier') + return typeof current.name === 'string' && dateNames.has(current.name) + if (current.type === 'ConditionalExpression') + return ( + isDateExpression(current.consequent, dateNames) || + isDateExpression(current.alternate, dateNames) + ) + if (current.type === 'LogicalExpression') + return isDateExpression(current.left, dateNames) || isDateExpression(current.right, dateNames) + return false +} + +/** + * Names bound to a `Date` anywhere in the file. The pass over-approximates scope — a name + * declared in one function marks same-named bindings elsewhere — which keeps the audit + * blind-spot-free at the cost of flagging a shadowed non-Date, resolvable by renaming. + */ +function collectDateNames(program: SyntaxNode): Set { + const names = new Set() + let changed = true + + const visit = (node: SyntaxNode) => { + const isBinding = + node.type === 'VariableDeclarator' || + node.type === 'ClassProperty' || + node.type === 'PropertyDefinition' + if (isBinding && isSyntaxNode(node.id ?? node.key)) { + const target = (node.id ?? node.key) as SyntaxNode + if ( + target.type === 'Identifier' && + typeof target.name === 'string' && + (isDateAnnotation(target.typeAnnotation ?? node.typeAnnotation) || + isDateExpression(node.init ?? node.value, names)) + ) { + if (!names.has(target.name)) { + names.add(target.name) + changed = true + } + } + } + if ( + (node.type === 'Identifier' && isDateAnnotation(node.typeAnnotation)) || + (node.type === 'TSPropertySignature' && isDateAnnotation(node.typeAnnotation)) + ) { + const name = isSyntaxNode(node.key) ? node.key.name : node.name + if (typeof name === 'string' && !names.has(name)) { + names.add(name) + changed = true + } + } + for (const child of getChildNodes(node)) visit(child) + } + + /** Re-run until stable so `const b = a` chains resolve regardless of declaration order. */ + while (changed) { + changed = false + visit(program) + } + return names +} + +function isSqlIdentifier(node: unknown): boolean { + return isSyntaxNode(node) && node.type === 'Identifier' && node.name === 'sql' +} + +/** Matches `` sql`…` `` and `` sql`…` `` (the generic wraps the tag in TSInstantiationExpression). */ +function isSqlTag(node: unknown): boolean { + if (isSqlIdentifier(node)) return true + return ( + isSyntaxNode(node) && + node.type === 'TSInstantiationExpression' && + isSqlIdentifier(node.expression) + ) +} + +function isSqlParamCall(node: SyntaxNode): boolean { + const callee = isSyntaxNode(node.callee) ? unwrap(node.callee) : undefined + return Boolean( + callee && + callee.type === 'MemberExpression' && + isSyntaxNode(callee.property) && + callee.property.name === 'param' && + isSqlIdentifier(callee.object) + ) +} + +/** + * A violation is excused only when the preceding line is a line comment whose + * text is exactly the documented annotation followed by a non-empty reason. + * Matching the marker anywhere on the line would let unrelated code — or a + * bare marker with no justification — silently disable the audit. + */ +function isAllowAnnotation(line: string | undefined): boolean { + const trimmed = (line ?? '').trim() + if (!trimmed.startsWith(ALLOW_ANNOTATION)) return false + return trimmed.slice(ALLOW_ANNOTATION.length).trim().length > 0 +} + +export function findSqlDateBindingViolations(source: string, file = 'source.ts'): Violation[] { + const syntaxTree = parse(source, { + sourceFilename: file, + sourceType: 'unambiguous', + errorRecovery: true, + plugins: [...(extname(file) === '.tsx' ? (['jsx'] as const) : []), 'typescript'], + }) + const program = syntaxTree.program as unknown as SyntaxNode + const dateNames = collectDateNames(program) + const lines = source.split('\n') + const violations: Violation[] = [] + + const report = (node: SyntaxNode, reason: string) => { + if (typeof node.start !== 'number' || typeof node.end !== 'number' || !node.loc) return + const line = node.loc.start.line + if (isAllowAnnotation(lines[line - 2])) return + violations.push({ file, line, expression: source.slice(node.start, node.end), reason }) + } + + const visit = (node: SyntaxNode) => { + if (node.type === 'TaggedTemplateExpression' && isSqlTag(node.tag)) { + const quasi = isSyntaxNode(node.quasi) ? node.quasi : undefined + const expressions = Array.isArray(quasi?.expressions) ? quasi.expressions : [] + for (const expression of expressions) { + if (isDateExpression(expression, dateNames)) { + report( + expression as SyntaxNode, + 'a Date interpolated into a raw sql template has no encoder; bind it with sql.param(date, table.column)' + ) + } + } + } + if (node.type === 'CallExpression' && isSqlParamCall(node)) { + const args = Array.isArray(node.arguments) ? node.arguments : [] + if (args.length === 1 && isDateExpression(args[0], dateNames)) { + report( + args[0] as SyntaxNode, + 'sql.param(date) has no encoder; pass the column as the second argument' + ) + } + } + for (const child of getChildNodes(node)) visit(child) + } + visit(program) + + return violations +} + +function collectSources(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue + const path = join(dir, entry.name) + if (entry.isDirectory()) collectSources(path, found) + else if (SOURCE_EXTENSIONS.has(extname(path)) && !path.endsWith('.d.ts')) found.push(path) + } + return found +} + +function main(): void { + const files = SCAN_DIRS.flatMap((dir) => collectSources(dir)) + const violations = files.flatMap((file) => + findSqlDateBindingViolations(readFileSync(file, 'utf8'), file) + ) + + if (violations.length > 0) { + console.error('Unbound Date values reach postgres-js through raw sql templates:') + for (const violation of violations) { + console.error( + ` ${relative(ROOT, violation.file)}:${violation.line} ${violation.expression}\n ${violation.reason}` + ) + } + console.error( + `\nDrizzle replaces postgres-js's temporal serializers with an identity function and maps` + + `\ntimestamps itself, so a Date outside column context is never serialized. Bind through` + + `\nthe column: sql.param(date, table.column). Annotate a genuine exception with` + + `\n${ALLOW_ANNOTATION} on the preceding line.` + ) + process.exit(1) + } + + console.log(`✓ ${files.length} files bind every sql-template Date through a column encoder`) +} + +if (import.meta.main) main()