diff --git a/bun.lock b/bun.lock index 57c49b9..e34523e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "code-index", "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@easier-idx/core": "^0.2.1", + "@easier-idx/core": "^0.3.1", "@easier-idx/embedding": "^0.2.2", "@modelcontextprotocol/sdk": "^1.27.1", "ignore": "^7.0.5", @@ -33,9 +33,9 @@ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "@easier-idx/clustering": ["@easier-idx/clustering@0.2.0", "", {}, "sha512-kkNE2lYIXyxzQs3iDol6QcTqz5OPgRhekorucNNCl74mHaCJGvbiR9PBW1lrWTjEoiEI0N3QSjAmyRqr3+BMuA=="], + "@easier-idx/clustering": ["@easier-idx/clustering@0.3.0", "", {}, "sha512-b3T6R86gXcKmv5tD1KbHmmEVPyNKeP0i/zAxoiULmSQf7rRfL7/d1++RfsAWxXXWdaYeGBcX0CIfXvlGN50uUQ=="], - "@easier-idx/core": ["@easier-idx/core@0.2.1", "", { "dependencies": { "@easier-idx/clustering": "0.2.0", "@easier-idx/logging": "0.1.0", "sqlite-vec": "^0.1.7" }, "optionalDependencies": { "better-sqlite3": "^11.10.0", "pg": "^8.16.3" } }, "sha512-riJWc5RYXQPUSTrGYFEWw34GUwYTN6uEiet2kypNXbiheul4Vhpsh5rM7p1j4zlef6LH1yoER8QP1j3dU+/gZA=="], + "@easier-idx/core": ["@easier-idx/core@0.3.1", "", { "dependencies": { "@easier-idx/clustering": "^0.3.0", "@easier-idx/logging": "^0.1.0", "sqlite-vec": "^0.1.7" }, "optionalDependencies": { "better-sqlite3": "^11.10.0", "pg": "^8.16.3" } }, "sha512-/vSnk6Vyj3KJfu5az46m4pO9NzM4ZWikuZO1amZcjTnNsmqxTJ5VopizQf91w4yrciVZAhC+rfpBQUI5iJfE8w=="], "@easier-idx/embedding": ["@easier-idx/embedding@0.2.2", "", { "dependencies": { "@easier-idx/core": "^0.2.0", "@easier-idx/logging": "^0.1.0" }, "optionalDependencies": { "openai": "^4.77.0" } }, "sha512-7Hw2LiTH5ykf+SR5N7rlsd7nQ9VRj5SnGk4d22vAUC9zhM/GtZR1BbHDpaD3zribIxerbx21RugB3m+caOHjqw=="], @@ -541,6 +541,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + "@easier-idx/embedding/@easier-idx/core": ["@easier-idx/core@0.2.1", "", { "dependencies": { "@easier-idx/clustering": "0.2.0", "@easier-idx/logging": "0.1.0", "sqlite-vec": "^0.1.7" }, "optionalDependencies": { "better-sqlite3": "^11.10.0", "pg": "^8.16.3" } }, "sha512-riJWc5RYXQPUSTrGYFEWw34GUwYTN6uEiet2kypNXbiheul4Vhpsh5rM7p1j4zlef6LH1yoER8QP1j3dU+/gZA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -553,6 +555,8 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@easier-idx/embedding/@easier-idx/core/@easier-idx/clustering": ["@easier-idx/clustering@0.2.0", "", {}, "sha512-kkNE2lYIXyxzQs3iDol6QcTqz5OPgRhekorucNNCl74mHaCJGvbiR9PBW1lrWTjEoiEI0N3QSjAmyRqr3+BMuA=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/package.json b/package.json index 40b7a90..bf67616 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@easier-idx/core": "^0.2.1", + "@easier-idx/core": "^0.3.1", "@easier-idx/embedding": "^0.2.2", "@modelcontextprotocol/sdk": "^1.27.1", "ignore": "^7.0.5", diff --git a/src/dedup/global-store-pg.ts b/src/dedup/global-store-pg.ts index a901676..1a8263d 100644 --- a/src/dedup/global-store-pg.ts +++ b/src/dedup/global-store-pg.ts @@ -12,6 +12,7 @@ */ import { getPg } from "../db/pg"; +import type { PgTx } from "../db/pg"; import { applyGlobalPgMigrations } from "../db/migrate"; import type { CodeindexConfig } from "../search/types"; import type { @@ -64,12 +65,13 @@ class PgGlobalStore implements GlobalDedupStore { const result = new Map(); if (hashes.length === 0) return result; const pg = await getPg(); + const hashPlaceholders = hashes.map((_, i) => `$${i + 4}`).join(","); const rows = (await pg.unsafe( `SELECT content_hash, skeleton, skeleton_entries, embedding::text AS embedding FROM file_blobs WHERE provider = $1 AND model = $2 AND dimensions = $3 - AND content_hash = ANY($4::text[])`, - [provider, model, dimensions, hashes], + AND content_hash IN (${hashPlaceholders})`, + [provider, model, dimensions, ...hashes], )) as Array<{ content_hash: string; skeleton: string | null; @@ -250,10 +252,11 @@ class PgGlobalStore implements GlobalDedupStore { }>; return rows.map((r) => r.content_hash); } + const placeholders = excludePackageIds.map((_, i) => `$${i + 1}`).join(","); const rows = (await pg.unsafe( `SELECT DISTINCT content_hash FROM package_files - WHERE NOT (package_id = ANY($1::int[]))`, - [excludePackageIds], + WHERE package_id NOT IN (${placeholders})`, + [...excludePackageIds], )) as Array<{ content_hash: string }>; return rows.map((r) => r.content_hash); } @@ -261,7 +264,8 @@ class PgGlobalStore implements GlobalDedupStore { async deletePackages(packageIds: number[]): Promise { if (packageIds.length === 0) return; const pg = await getPg(); - await pg.unsafe(`DELETE FROM packages WHERE id = ANY($1::int[])`, [packageIds]); + const placeholders = packageIds.map((_, i) => `$${i + 1}`).join(","); + await pg.unsafe(`DELETE FROM packages WHERE id IN (${placeholders})`, [...packageIds]); } async countBlobsExcept(liveHashes: Set): Promise { @@ -283,19 +287,24 @@ class PgGlobalStore implements GlobalDedupStore { )) as Array<{ n: number }>; return rows[0]?.n ?? 0; } - const rows = (await pg.unsafe( - `SELECT COUNT(*)::int AS n FROM file_blobs fb - WHERE NOT (fb.content_hash = ANY($1::text[])) - AND NOT EXISTS ( - SELECT 1 FROM repo_files rf - WHERE rf.content_hash = fb.content_hash - AND rf.provider = fb.provider - AND rf.model = fb.model - AND rf.dimensions = fb.dimensions - )`, - [Array.from(liveHashes)], - )) as Array<{ n: number }>; - return rows[0]?.n ?? 0; + // Spreading every live hash into its own bind would risk PG's 65535 + // parameter cap on large dedup sets; load them into a temp table in + // chunks instead so the filter scales independently of |liveHashes|. + return pg.begin(async (tx) => { + await loadLiveHashesIntoTempTable(tx, liveHashes); + const rows = (await tx.unsafe( + `SELECT COUNT(*)::int AS n FROM file_blobs fb + WHERE NOT EXISTS (SELECT 1 FROM _live_hashes WHERE h = fb.content_hash) + AND NOT EXISTS ( + SELECT 1 FROM repo_files rf + WHERE rf.content_hash = fb.content_hash + AND rf.provider = fb.provider + AND rf.model = fb.model + AND rf.dimensions = fb.dimensions + )`, + )) as Array<{ n: number }>; + return rows[0]?.n ?? 0; + }); } async deleteBlobsExcept(liveHashes: Set): Promise { @@ -314,20 +323,22 @@ class PgGlobalStore implements GlobalDedupStore { )) as Array<{ content_hash: string }>; return rows.length; } - const rows = (await pg.unsafe( - `DELETE FROM file_blobs fb - WHERE NOT (fb.content_hash = ANY($1::text[])) - AND NOT EXISTS ( - SELECT 1 FROM repo_files rf - WHERE rf.content_hash = fb.content_hash - AND rf.provider = fb.provider - AND rf.model = fb.model - AND rf.dimensions = fb.dimensions - ) - RETURNING content_hash`, - [Array.from(liveHashes)], - )) as Array<{ content_hash: string }>; - return rows.length; + return pg.begin(async (tx) => { + await loadLiveHashesIntoTempTable(tx, liveHashes); + const rows = (await tx.unsafe( + `DELETE FROM file_blobs fb + WHERE NOT EXISTS (SELECT 1 FROM _live_hashes WHERE h = fb.content_hash) + AND NOT EXISTS ( + SELECT 1 FROM repo_files rf + WHERE rf.content_hash = fb.content_hash + AND rf.provider = fb.provider + AND rf.model = fb.model + AND rf.dimensions = fb.dimensions + ) + RETURNING content_hash`, + )) as Array<{ content_hash: string }>; + return rows.length; + }); } async sweepOrphanedBlobs(opts: { dryRun: boolean }): Promise { @@ -369,6 +380,24 @@ class PgGlobalStore implements GlobalDedupStore { } } +// --------------------------------------------------------------------------- +// Temp-table loader for large hash filters +// --------------------------------------------------------------------------- + +// 5000 rows per insert leaves comfortable headroom under PG's 65535 +// bind-parameter ceiling without causing many round-trips for typical sets. +const HASH_INSERT_CHUNK = 5000; + +async function loadLiveHashesIntoTempTable(tx: PgTx, liveHashes: Set): Promise { + await tx.unsafe(`CREATE TEMP TABLE _live_hashes (h text PRIMARY KEY) ON COMMIT DROP`); + const liveArr = Array.from(liveHashes); + for (let i = 0; i < liveArr.length; i += HASH_INSERT_CHUNK) { + const slice = liveArr.slice(i, i + HASH_INSERT_CHUNK); + const placeholders = slice.map((_, j) => `($${j + 1})`).join(","); + await tx.unsafe(`INSERT INTO _live_hashes (h) VALUES ${placeholders}`, slice); + } +} + // --------------------------------------------------------------------------- // pgvector literal helpers — pgvector serialises as e.g. "[0.1,0.2,0.3]" // --------------------------------------------------------------------------- diff --git a/src/mcp/tools/files.ts b/src/mcp/tools/files.ts index 620db93..b1a81b7 100644 --- a/src/mcp/tools/files.ts +++ b/src/mcp/tools/files.ts @@ -146,9 +146,18 @@ export function registerFileTools(ctx: McpToolContext): void { const scopedRepoIds = session?.repoIds ?? null; if (config.store === "pg") { - const repoFilter = scopedRepoIds ? `AND nf.repo_id = ANY($4::int[])` : ""; + // Bun 1.3.13's bun:sql cannot encode JS arrays for ANY($N) on PG18 + // (Bun PR #29552); expand into numbered IN placeholders instead. + // An empty scoped list is a valid "no access" signal — preserve the + // original ANY-on-empty semantics by short-circuiting to no rows. + if (scopedRepoIds && scopedRepoIds.length === 0) return mcpSuccess([]); const params: unknown[] = [repoRoot, filePath, depth]; - if (scopedRepoIds) params.push(scopedRepoIds); + let repoFilter = ""; + if (scopedRepoIds) { + const placeholders = scopedRepoIds.map((_, i) => `$${params.length + i + 1}`).join(","); + repoFilter = `AND nf.repo_id IN (${placeholders})`; + params.push(...scopedRepoIds); + } const query = dir === "dependencies" ? `WITH RECURSIVE chain AS ( @@ -265,8 +274,16 @@ export function registerFileTools(ctx: McpToolContext): void { const scopedRepoIds = session?.repoIds ?? null; if (config.store === "pg") { - const query = scopedRepoIds - ? `SELECT sr.name AS source_repo, tr.name AS target_repo, + // Bun 1.3.13's bun:sql cannot encode JS arrays for ANY($N) on PG18 + // (Bun PR #29552); expand into numbered IN placeholders instead. + // An empty scoped list is a valid "no access" signal — preserve the + // original ANY-on-empty semantics by short-circuiting to no rows. + if (scopedRepoIds && scopedRepoIds.length === 0) return mcpSuccess([]); + let query: string; + let params: unknown[]; + if (scopedRepoIds) { + const placeholders = scopedRepoIds.map((_, i) => `$${i + 1}`).join(","); + query = `SELECT sr.name AS source_repo, tr.name AS target_repo, sf.file_path AS source_file, tf.file_path AS target_file, e.imported_module FROM cross_repo_edges e @@ -274,9 +291,11 @@ export function registerFileTools(ctx: McpToolContext): void { JOIN repos tr ON tr.id = e.target_repo_id JOIN files sf ON sf.id = e.source_file_id JOIN files tf ON tf.id = e.target_file_id - WHERE (e.source_repo_id = ANY($1::int[]) OR e.target_repo_id = ANY($1::int[])) - ORDER BY sr.name, tr.name` - : `SELECT sr.name AS source_repo, tr.name AS target_repo, + WHERE (e.source_repo_id IN (${placeholders}) OR e.target_repo_id IN (${placeholders})) + ORDER BY sr.name, tr.name`; + params = [...scopedRepoIds]; + } else { + query = `SELECT sr.name AS source_repo, tr.name AS target_repo, sf.file_path AS source_file, tf.file_path AS target_file, e.imported_module FROM cross_repo_edges e @@ -285,7 +304,8 @@ export function registerFileTools(ctx: McpToolContext): void { JOIN files sf ON sf.id = e.source_file_id JOIN files tf ON tf.id = e.target_file_id ORDER BY sr.name, tr.name`; - const params = scopedRepoIds ? [scopedRepoIds] : []; + params = []; + } const rows = await withMcpScope(session, async (tx) => tx.unsafe(query, params)); return mcpSuccess(rows); } else { @@ -352,23 +372,34 @@ export function registerFileTools(ctx: McpToolContext): void { const scopedRepoIds = session?.repoIds ?? null; if (config.store === "pg") { - const query = scopedRepoIds - ? `SELECT f.file_path, f.skeleton_entries, r.name AS repo_name + // Bun 1.3.13's bun:sql cannot encode JS arrays for ANY($N) on PG18 + // (Bun PR #29552); expand into numbered IN placeholders instead. + // An empty scoped list is a valid "no access" signal — preserve the + // original ANY-on-empty semantics by short-circuiting to no rows. + if (scopedRepoIds && scopedRepoIds.length === 0) return mcpSuccess([]); + let query: string; + let params: unknown[]; + if (scopedRepoIds) { + const placeholders = scopedRepoIds.map((_, i) => `$${i + 2}`).join(","); + query = `SELECT f.file_path, f.skeleton_entries, r.name AS repo_name FROM files f JOIN repos r ON r.id = f.repo_id WHERE f.skeleton LIKE $1 ESCAPE '\\' AND (f.skeleton LIKE '%implements%' OR f.skeleton LIKE '%extends%' OR f.skeleton LIKE '%: %' OR f.skeleton LIKE '%conform%') - AND r.id = ANY($2::int[]) - LIMIT 100` - : `SELECT f.file_path, f.skeleton_entries, r.name AS repo_name + AND r.id IN (${placeholders}) + LIMIT 100`; + params = [pattern, ...scopedRepoIds]; + } else { + query = `SELECT f.file_path, f.skeleton_entries, r.name AS repo_name FROM files f JOIN repos r ON r.id = f.repo_id WHERE f.skeleton LIKE $1 ESCAPE '\\' AND (f.skeleton LIKE '%implements%' OR f.skeleton LIKE '%extends%' OR f.skeleton LIKE '%: %' OR f.skeleton LIKE '%conform%') LIMIT 100`; - const params = scopedRepoIds ? [pattern, scopedRepoIds] : [pattern]; + params = [pattern]; + } const rows = await withMcpScope(session, async (tx) => tx.unsafe(query, params)); return mcpSuccess(rows); } else { @@ -420,23 +451,34 @@ export function registerFileTools(ctx: McpToolContext): void { const scopedRepoIds = session?.repoIds ?? null; if (config.store === "pg") { - const query = scopedRepoIds - ? `SELECT DISTINCT sf.file_path, r.name AS repo_name, fi.imported_module + // Bun 1.3.13's bun:sql cannot encode JS arrays for ANY($N) on PG18 + // (Bun PR #29552); expand into numbered IN placeholders instead. + // An empty scoped list is a valid "no access" signal — preserve the + // original ANY-on-empty semantics by short-circuiting to no rows. + if (scopedRepoIds && scopedRepoIds.length === 0) return mcpSuccess([]); + let query: string; + let params: unknown[]; + if (scopedRepoIds) { + const placeholders = scopedRepoIds.map((_, i) => `$${i + 2}`).join(","); + query = `SELECT DISTINCT sf.file_path, r.name AS repo_name, fi.imported_module FROM file_imports fi JOIN files sf ON sf.id = fi.source_file_id JOIN repos r ON r.id = sf.repo_id WHERE fi.imported_module LIKE $1 ESCAPE '\\' - AND r.id = ANY($2::int[]) + AND r.id IN (${placeholders}) ORDER BY r.name, sf.file_path - LIMIT 100` - : `SELECT DISTINCT sf.file_path, r.name AS repo_name, fi.imported_module + LIMIT 100`; + params = [pattern, ...scopedRepoIds]; + } else { + query = `SELECT DISTINCT sf.file_path, r.name AS repo_name, fi.imported_module FROM file_imports fi JOIN files sf ON sf.id = fi.source_file_id JOIN repos r ON r.id = sf.repo_id WHERE fi.imported_module LIKE $1 ESCAPE '\\' ORDER BY r.name, sf.file_path LIMIT 100`; - const params = scopedRepoIds ? [pattern, scopedRepoIds] : [pattern]; + params = [pattern]; + } const rows = await withMcpScope(session, async (tx) => tx.unsafe(query, params)); return mcpSuccess(rows); } else { diff --git a/src/repo.ts b/src/repo.ts index c03585c..71037f9 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -1,7 +1,7 @@ import path from "path"; import { existsSync } from "fs"; import { loadConfig } from "./config"; -import { getPg, pgUnsafe } from "./db/pg"; +import { getPg } from "./db/pg"; import { getSqlite } from "./db/sqlite"; import { ensurePgSchema, ensureSqliteSchema } from "./db/schema"; import { getRepoOrigin, getRepoName } from "./index/commits"; @@ -36,7 +36,7 @@ interface ListRow { // --------------------------------------------------------------------------- import type { StoreOps } from "@easier-idx/core"; -import { pgToSqlite } from "@easier-idx/core/db/store"; +import { createPgStoreOps, createSqliteStoreOps } from "@easier-idx/core/db/store"; export type { StoreOps }; @@ -49,29 +49,12 @@ export async function getStoreOps(repoRoot: string): Promise<{ store: string; op const config = await loadConfig(repoRoot); if (config.store === "pg") { - return { - store: "pg", - ops: { - query: async (sql: string, params?: unknown[]) => (await pgUnsafe(sql, params)) as T[], - run: async (sql: string, params?: unknown[]) => { - await pgUnsafe(sql, params); - }, - }, - }; + const pg = await getPg(); + return { store: "pg", ops: createPgStoreOps(pg) }; } const db = await getSqlite(repoRoot); - type SqlBindings = (string | number | bigint | boolean | null | Uint8Array)[]; - return { - store: "sqlite", - ops: { - query: async (sql: string, params?: unknown[]) => - db.prepare(pgToSqlite(sql)).all(...((params ?? []) as SqlBindings)) as T[], - run: async (sql: string, params?: unknown[]) => { - db.prepare(pgToSqlite(sql)).run(...((params ?? []) as SqlBindings)); - }, - }, - }; + return { store: "sqlite", ops: createSqliteStoreOps(db) }; } // --------------------------------------------------------------------------- diff --git a/src/search/query.ts b/src/search/query.ts index 56ecea4..455deb8 100644 --- a/src/search/query.ts +++ b/src/search/query.ts @@ -97,19 +97,17 @@ async function withSnippets( if (filePaths.length > 0 && resultRepoIds.length > 0) { if (config.store === "pg") { // Avoid ANY($n) with array params — Bun.SQL misserialises nested - // arrays. Use IN-list for validated integer repo IDs, placeholders - // for file paths. - for (const id of resultRepoIds) { - if (typeof id !== "number" || !Number.isInteger(id)) - throw new Error(`Invalid repo ID: ${String(id)}`); - } - const pathPh = filePaths.map((_, i) => `$${i + 1}`).join(","); + // arrays. Use IN ($1,$2,...) with positional placeholders instead. const rows = await withRepoScope(resultRepoIds, async (tx) => { + const repoPlaceholders = resultRepoIds.map((_, i) => `$${i + 1}`).join(","); + const pathPlaceholders = filePaths + .map((_, i) => `$${resultRepoIds.length + i + 1}`) + .join(","); return (await tx.unsafe( `SELECT repo_id, file_path, skeleton_entries FROM files - WHERE repo_id IN (${resultRepoIds.join(",")}) - AND file_path IN (${pathPh})`, - filePaths as never[], + WHERE repo_id IN (${repoPlaceholders}) + AND file_path IN (${pathPlaceholders})`, + [...resultRepoIds, ...filePaths], )) as { repo_id: string; file_path: string; skeleton_entries: string | null }[]; }); for (const row of rows) { @@ -228,12 +226,11 @@ async function withCrossRepoEdges( let edgeQuery = `SELECT DISTINCT source_repo_id, target_repo_id FROM cross_repo_edges`; const edgeParams: unknown[] = []; if (scopedRepoIds !== null && scopedRepoIds.length > 0) { + // Reuse the same $1..$N placeholders for both IN clauses (PG's wire + // protocol allows it) so we only bind the repo-id list once. const placeholders = scopedRepoIds.map((_, i) => `$${i + 1}`).join(","); - const placeholders2 = scopedRepoIds - .map((_, i) => `$${i + 1 + scopedRepoIds.length}`) - .join(","); - edgeQuery += ` WHERE (source_repo_id IN (${placeholders}) OR target_repo_id IN (${placeholders2}))`; - edgeParams.push(...scopedRepoIds, ...scopedRepoIds); + edgeQuery += ` WHERE (source_repo_id IN (${placeholders}) OR target_repo_id IN (${placeholders}))`; + edgeParams.push(...scopedRepoIds); } const allEdges = await withRepoScope(edgeRepoIds, async (tx) => { return (await tx.unsafe(edgeQuery, edgeParams)) as { diff --git a/src/search/search-pg.ts b/src/search/search-pg.ts index 83b7c05..bdb319c 100644 --- a/src/search/search-pg.ts +++ b/src/search/search-pg.ts @@ -198,17 +198,13 @@ export async function searchPgInTransaction( const pathArr = [...new Set(junctionRows.map((r) => r.file_path))]; // Avoid ANY($n) with array params — Bun.SQL misserialises nested // arrays, causing "number of array dimensions (N) exceeds maximum". - // Use IN-list interpolation for validated integer repo IDs and - // parameterised placeholders for file paths instead. - for (const id of repoIdArr) { - if (typeof id !== "number" || !Number.isInteger(id)) - throw new Error(`Invalid repo ID: ${String(id)}`); - } - const pathPlaceholders = pathArr.map((_, i) => `$${i + 1}`).join(","); + // Use IN ($1,$2,...) with positional placeholders instead. + const repoPlaceholders = repoIdArr.map((_, i) => `$${i + 1}`).join(","); + const pathPlaceholders = pathArr.map((_, i) => `$${repoIdArr.length + i + 1}`).join(","); const idRows = (await pg.unsafe( `SELECT id, repo_id, file_path FROM files - WHERE repo_id IN (${repoIdArr.join(",")}) AND file_path IN (${pathPlaceholders})`, - pathArr as never[], + WHERE repo_id IN (${repoPlaceholders}) AND file_path IN (${pathPlaceholders})`, + [...repoIdArr, ...pathArr] as never[], )) as { id: string; repo_id: string; file_path: string }[]; for (const r of idRows) { idMap.set(`${r.repo_id}:${r.file_path}`, r.id); diff --git a/src/xref.ts b/src/xref.ts index 2d30747..4533341 100644 --- a/src/xref.ts +++ b/src/xref.ts @@ -149,7 +149,7 @@ async function xrefPg( SELECT DISTINCT rf.content_hash, rf.file_path, rf.repo_id, r.name AS repo_name FROM repo_files rf JOIN repos r ON r.id = rf.repo_id - WHERE rf.content_hash = ANY(${definitionHashes}) + WHERE rf.content_hash IN ${pg(definitionHashes)} AND rf.provider = ${provider} AND rf.model = ${model} AND rf.dimensions = ${dimensions} @@ -185,7 +185,7 @@ async function xrefPg( FROM file_imports fi JOIN files f ON fi.source_file_id = f.id JOIN repos r ON f.repo_id = r.id - WHERE fi.resolved_file_id = ANY(${fileIdArray}) + WHERE fi.resolved_file_id IN ${pg(fileIdArray)} `; const consumerMatches = buildConsumerMatches(consumers, 0.5); @@ -195,7 +195,7 @@ async function xrefPg( FROM cross_repo_edges cre JOIN files f ON cre.source_file_id = f.id JOIN repos r ON f.repo_id = r.id - WHERE cre.target_file_id = ANY(${fileIdArray}) + WHERE cre.target_file_id IN ${pg(fileIdArray)} `; const crossEdgeMatches = buildConsumerMatches(crossEdges, 0.4);