Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/rows/services/rowSearch.server.ts
T

149 lines
4.3 KiB
TypeScript

/**
* 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<SearchHits> {
const limit = input.limit ?? 50;
const q = input.query.trim();
const where: Record<string, unknown> = {
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<string, unknown> & { id: string; tenantId: string },
})),
estimatedTotalHits: rows.length,
processingTimeMs: 0,
};
}
export async function searchRows(input: SearchRowsInput): Promise<RowSearchResult> {
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<string, unknown>): 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, '\\"');
}