groceries: fix search 500 (pg_trgm word_similarity lives in public, app search_path is $user); guard test for unqualified trgm calls
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
This commit is contained in:
27
src/lib/server/groceries/trgm-guard.test.ts
Normal file
27
src/lib/server/groceries/trgm-guard.test.ts
Normal file
@@ -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 =
|
||||
/(?<![\w.])(?:strict_word_similarity|word_similarity|show_trgm|show_limit)\s*\(|(?<![\w.])similarity\s*\(\$\{/g;
|
||||
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((f) => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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' } });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user