Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -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);
}