diff --git a/src/lib/server/groceries/trgm-guard.test.ts b/src/lib/server/groceries/trgm-guard.test.ts new file mode 100644 index 0000000..a7f5efb --- /dev/null +++ b/src/lib/server/groceries/trgm-guard.test.ts @@ -0,0 +1,27 @@ +// The app role's search_path is "$user" only, while pg_trgm lives in schema public, so an +// unqualified similarity()/word_similarity() call fails in prod with "function ... does not +// exist" (it broke grocery search on 2026-10-24). Every pg_trgm function must be public.-qualified. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Bare similarity( is also a JS name, so only flag it when it takes a SQL parameter. +const TRGM = + /(? { + const p = join(dir, f); + if (statSync(p).isDirectory()) return files(p); + return /\.(ts|js|svelte)$/.test(f) && !/\.test\.ts$/.test(f) ? [p] : []; + }); +} + +describe('pg_trgm calls', () => { + it('are schema-qualified with public.', () => { + const bad = files('src').flatMap((p) => + [...readFileSync(p, 'utf8').matchAll(TRGM)].map((m) => `${p}: ${m[0]}`) + ); + expect(bad).toEqual([]); + }); +}); diff --git a/src/routes/trackers/groceries/+page.server.ts b/src/routes/trackers/groceries/+page.server.ts index fc105e6..7a52edc 100644 --- a/src/routes/trackers/groceries/+page.server.ts +++ b/src/routes/trackers/groceries/+page.server.ts @@ -94,7 +94,7 @@ export const load: PageServerLoad = async (event) => { if (multi) conds.push(s`g.store_count >= 2`); const where = conds.reduce((a, c) => s`${a} and ${c}`); const order = q - ? s`g.favorite desc, (lower(g.name) = ${q}) desc, word_similarity(${q}, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name` + ? s`g.favorite desc, (lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name` : s`g.favorite desc, g.store_count desc, g.item_count desc, g.name`; const [groups, [{ total }]] = await Promise.all([ diff --git a/src/routes/trackers/groceries/search/+server.ts b/src/routes/trackers/groceries/search/+server.ts index 9fbe956..6db7249 100644 --- a/src/routes/trackers/groceries/search/+server.ts +++ b/src/routes/trackers/groceries/search/+server.ts @@ -17,7 +17,7 @@ export const GET: RequestHandler = async (event) => { select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount" from groceries.groups where merged_into_id is null and ${conds.reduce((a, c) => s`${a} and ${c}`)} - order by word_similarity(${q}, lower(name)) desc, store_count desc, item_count desc, id + order by public.word_similarity(${q}::text, lower(name)) desc, store_count desc, item_count desc, id limit 15`; return json(rows, { headers: { 'cache-control': 'private, no-store' } }); };