/** * Generic Rows public search — hot-path migration to Meilisearch. * * Wraps `searchWithFallback({ index: 'rows', tenantId, query, * fallback })` so the rows-engine row-list / row-search code-paths can * adopt the typed client without further refactors. The `entityId` * filter scopes the search to one entity type (e.g. "company", "deal", * any no-code-entity) which mirrors how the legacy SQL path is keyed * to a single entity at a time. * * Fallback semantics: when MEILI_URL is unset, when SEARCH_BACKEND=sql * is forced, or when Meilisearch is unavailable, the helper falls back * to a SQL ILIKE-against-RowValue scan over the entity's rows. */ import { createSearchClient, rowsIndex, searchWithFallback, type SearchClient, type SearchFallback, type SearchHits, } from "@toolctrl/search"; import { db } from "~/utils/db.server"; let cachedClient: SearchClient | undefined; let cachedUrl: string | undefined; function getSearchClient(): SearchClient | undefined { const url = process.env.MEILI_URL; if (!url || process.env.SEARCH_BACKEND === "sql") return undefined; if (cachedClient && cachedUrl === url) return cachedClient; cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined }); cachedUrl = url; return cachedClient; } export interface SearchRowsInput { /** Tenant scope — REQUIRED for both backends. */ readonly tenantId: string; /** Entity row-bucket — REQUIRED. Maps to the `entityId` filter on * the Meilisearch side and to a WHERE-clause on the SQL side. */ readonly entityId: string; /** User-supplied query string. Empty string returns the first page. */ readonly query: string; /** Optional cap on result count. Defaults to 50. */ readonly limit?: number; } export interface RowSearchHit { readonly id: string; readonly folio: number; readonly entityName: string; } export interface RowSearchResult { readonly hits: readonly RowSearchHit[]; readonly total: number; readonly fellBack: boolean; } async function sqlFallback(input: SearchRowsInput): Promise { const limit = input.limit ?? 50; const q = input.query.trim(); const where: Record = { tenantId: input.tenantId, entityId: input.entityId, deletedAt: null, }; if (q.length > 0) { where.values = { some: { textValue: { contains: q, mode: "insensitive" }, }, }; } const rows = await db.row.findMany({ where: where as never, take: limit, orderBy: [{ order: "desc" }, { createdAt: "desc" }], select: { id: true, folio: true, entity: { select: { name: true } }, }, }); return { hits: rows.map((row) => ({ document: { id: row.id, tenantId: input.tenantId, folio: typeof row.folio === "number" ? row.folio : 0, entityId: input.entityId, entityName: row.entity?.name ?? "", } as Record & { id: string; tenantId: string }, })), estimatedTotalHits: rows.length, processingTimeMs: 0, }; } export async function searchRows(input: SearchRowsInput): Promise { const fallback: SearchFallback = async () => sqlFallback(input); const client = getSearchClient(); if (!client) { const sql = await sqlFallback(input); return { hits: sql.hits.map((hit) => toRowHit(hit.document)), total: sql.estimatedTotalHits ?? sql.hits.length, fellBack: true, }; } let fellBack = false; const hits = await searchWithFallback({ client, schema: rowsIndex, tenantId: input.tenantId, query: input.query, fallback, onFallback: () => { fellBack = true; }, options: { limit: input.limit ?? 50, extraFilters: `entityId = "${escapeFilterValue(input.entityId)}"`, }, }); return { hits: hits.hits.map((hit) => toRowHit(hit.document)), total: hits.estimatedTotalHits ?? hits.hits.length, fellBack, }; } function toRowHit(document: Record): RowSearchHit { return { id: String(document.id ?? ""), folio: typeof document.folio === "number" ? document.folio : 0, entityName: typeof document.entityName === "string" ? document.entityName : "", }; } function escapeFilterValue(value: string): string { return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"'); }