Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+79
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createSearchClient: vi.fn(),
|
||||
searchWithFallback: vi.fn(),
|
||||
findKnowledgeBaseArticles: vi.fn(),
|
||||
findKnowledgeBaseArticlesByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/search", () => ({
|
||||
createSearchClient: mocks.createSearchClient,
|
||||
kbArticlesIndex: { name: "kb_articles", primaryKey: "id" },
|
||||
searchWithFallback: mocks.searchWithFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../db/kbArticles.db.server", () => ({
|
||||
findKnowledgeBaseArticles: mocks.findKnowledgeBaseArticles,
|
||||
findKnowledgeBaseArticlesByIds: mocks.findKnowledgeBaseArticlesByIds,
|
||||
}));
|
||||
|
||||
import { searchKnowledgeBaseArticles } from "./kbArticleSearch.server";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("searchKnowledgeBaseArticles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.MEILI_URL = "http://meili.test";
|
||||
delete process.env.SEARCH_BACKEND;
|
||||
mocks.createSearchClient.mockReturnValue({ index: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("hydrates Meilisearch hits by ID instead of scanning the full SQL search set", async () => {
|
||||
const articleA = { id: "article_a", title: "A" };
|
||||
const articleB = { id: "article_b", title: "B" };
|
||||
|
||||
mocks.searchWithFallback.mockResolvedValue({
|
||||
hits: [{ document: { id: "article_b" } }, { document: { id: "article_a" } }],
|
||||
estimatedTotalHits: 2,
|
||||
processingTimeMs: 3,
|
||||
});
|
||||
mocks.findKnowledgeBaseArticlesByIds.mockResolvedValue([articleA, articleB]);
|
||||
|
||||
await expect(
|
||||
searchKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: "docs",
|
||||
language: "de",
|
||||
query: "billing",
|
||||
tenantId: "tenant_1",
|
||||
})
|
||||
).resolves.toEqual([articleB, articleA]);
|
||||
|
||||
expect(mocks.findKnowledgeBaseArticlesByIds).toHaveBeenCalledWith({
|
||||
knowledgeBaseSlug: "docs",
|
||||
language: "de",
|
||||
ids: ["article_b", "article_a"],
|
||||
});
|
||||
expect(mocks.findKnowledgeBaseArticles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes the language value in the Meilisearch filter to prevent filter injection", async () => {
|
||||
mocks.searchWithFallback.mockResolvedValue({ hits: [], estimatedTotalHits: 0, processingTimeMs: 1 });
|
||||
|
||||
await searchKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: "docs",
|
||||
// A crafted language attempting to break out of the quoted filter string.
|
||||
language: 'de" OR isPublished = "true',
|
||||
query: "billing",
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
const passedOptions = mocks.searchWithFallback.mock.calls[0]?.[0]?.options;
|
||||
expect(passedOptions.extraFilters).toBe('language = "de\\" OR isPublished = \\"true" AND isPublished = true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* KB-article public search — hot-path migration to Meilisearch.
|
||||
*
|
||||
* The function exposes the same return shape as the legacy
|
||||
* `findKnowledgeBaseArticles` SQL implementation so the consuming
|
||||
* KnowledgeBaseService.search code path can adopt it without further
|
||||
* refactors. When MEILI_URL is unset (local dev that did not enable
|
||||
* Meilisearch), or SEARCH_BACKEND=sql is forced, we transparently fall
|
||||
* back to the legacy LIKE-based query. The same SQL path also engages
|
||||
* automatically when Meilisearch raises a connectivity error — that is
|
||||
* what `searchWithFallback` is for.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSearchClient,
|
||||
kbArticlesIndex,
|
||||
searchWithFallback,
|
||||
type SearchClient,
|
||||
type SearchFallback,
|
||||
} from "@toolctrl/search";
|
||||
import {
|
||||
findKnowledgeBaseArticlesByIds,
|
||||
findKnowledgeBaseArticles,
|
||||
type KnowledgeBaseArticleWithDetails,
|
||||
} from "../db/kbArticles.db.server";
|
||||
|
||||
let cachedClient: SearchClient | undefined;
|
||||
let cachedUrl: string | undefined;
|
||||
|
||||
// Escape values interpolated into a Meilisearch filter expression so a crafted
|
||||
// `language` (sourced from the optional `($lang)` URL segment) cannot break out
|
||||
// of the quoted string and inject filter operators. Mirrors rowSearch.server.ts.
|
||||
function escapeFilterValue(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
|
||||
}
|
||||
|
||||
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 SearchKnowledgeBaseArticlesInput {
|
||||
readonly knowledgeBaseSlug: string;
|
||||
readonly language: string | undefined;
|
||||
readonly query: string;
|
||||
/**
|
||||
* Tenant scope. Required when the Meilisearch path is engaged; the SQL
|
||||
* fallback ignores it because the (knowledgeBaseSlug, language) filter
|
||||
* already isolates results within a KB.
|
||||
*/
|
||||
readonly tenantId: string | undefined;
|
||||
}
|
||||
|
||||
export async function searchKnowledgeBaseArticles(
|
||||
input: SearchKnowledgeBaseArticlesInput
|
||||
): Promise<KnowledgeBaseArticleWithDetails[]> {
|
||||
const sqlFallback: SearchFallback = async () => {
|
||||
const rows = await findKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
query: input.query,
|
||||
});
|
||||
return {
|
||||
hits: rows.map((row) => ({ document: { ...row, id: row.id, tenantId: input.tenantId ?? "" } as Record<string, unknown> & { id: string | number } })),
|
||||
estimatedTotalHits: rows.length,
|
||||
processingTimeMs: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const client = getSearchClient();
|
||||
if (!client || !input.tenantId) {
|
||||
// No Meilisearch backend configured or no tenantId in scope —
|
||||
// the SQL path is the authoritative source.
|
||||
const rows = await findKnowledgeBaseArticles({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
query: input.query,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
const hits = await searchWithFallback({
|
||||
client,
|
||||
schema: kbArticlesIndex,
|
||||
tenantId: input.tenantId,
|
||||
query: input.query,
|
||||
fallback: sqlFallback,
|
||||
options: input.language
|
||||
? { extraFilters: `language = "${escapeFilterValue(input.language)}" AND isPublished = true` }
|
||||
: { extraFilters: "isPublished = true" },
|
||||
});
|
||||
|
||||
// The Meilisearch hit-stream doesn't carry the full ORM relation graph
|
||||
// the route expects (category/section/related-articles); look the
|
||||
// resolved IDs back up against the SQL store so the consumer keeps
|
||||
// its existing typed return shape.
|
||||
const ids = hits.hits.map((hit) => String(hit.document.id));
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await findKnowledgeBaseArticlesByIds({
|
||||
knowledgeBaseSlug: input.knowledgeBaseSlug,
|
||||
language: input.language,
|
||||
ids,
|
||||
});
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
return ids.map((id) => byId.get(id)).filter((row): row is KnowledgeBaseArticleWithDetails => row !== undefined);
|
||||
}
|
||||
Reference in new issue
Block a user