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
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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, '\\"');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Unit tests for searchRows — covers the SQL fallback contract: tenant
|
||||
* + entity scoping, value-text predicate, limit, and total accounting.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
row: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import { searchRows } from "./rowSearch.server";
|
||||
|
||||
const dbMock = db as unknown as {
|
||||
row: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const originalMeili = process.env.MEILI_URL;
|
||||
const originalBackend = process.env.SEARCH_BACKEND;
|
||||
|
||||
afterAll(() => {
|
||||
if (originalMeili === undefined) delete process.env.MEILI_URL;
|
||||
else process.env.MEILI_URL = originalMeili;
|
||||
if (originalBackend === undefined) delete process.env.SEARCH_BACKEND;
|
||||
else process.env.SEARCH_BACKEND = originalBackend;
|
||||
});
|
||||
|
||||
describe("searchRows (sql fallback)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MEILI_URL;
|
||||
delete process.env.SEARCH_BACKEND;
|
||||
dbMock.row.findMany.mockReset();
|
||||
});
|
||||
|
||||
it("scopes by tenantId + entityId and maps results", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([
|
||||
{ id: "row-1", folio: 12, entity: { name: "deal" } },
|
||||
{ id: "row-2", folio: 7, entity: { name: "deal" } },
|
||||
]);
|
||||
|
||||
const result = await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "acme" });
|
||||
|
||||
expect(dbMock.row.findMany).toHaveBeenCalledTimes(1);
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.tenantId).toBe("tenant-1");
|
||||
expect(where.entityId).toBe("entity-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.values).toBeDefined();
|
||||
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.hits).toEqual([
|
||||
{ id: "row-1", folio: 12, entityName: "deal" },
|
||||
{ id: "row-2", folio: 7, entityName: "deal" },
|
||||
]);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it("omits value-text predicate when the query is empty", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "" });
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honours the limit option", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "x", limit: 25 });
|
||||
expect(dbMock.row.findMany.mock.calls[0][0].take).toBe(25);
|
||||
});
|
||||
|
||||
it("defaults folio to 0 when missing on the row", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([{ id: "row-x", folio: null, entity: null }]);
|
||||
const result = await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "x" });
|
||||
expect(result.hits[0]).toEqual({ id: "row-x", folio: 0, entityName: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Outbox-driven Meilisearch indexing for the rows-engine.
|
||||
*
|
||||
* `RowsApi.create / update / del` invokes one of `emitRowSearchUpsert` /
|
||||
* `emitRowSearchDelete` AFTER the canonical DB write so the
|
||||
* `search.index.*.upsert | .delete` outbox event is published as a
|
||||
* post-write projection update. The outbox event-router (#48) maps
|
||||
* `search.index.*` to the `outbox.search-index` BullMQ queue where the
|
||||
* search-indexer worker (apps/worker/src/jobs/search-indexer.ts) picks
|
||||
* the event up and upserts/deletes the Meilisearch document.
|
||||
*
|
||||
* CRM contacts are stored as rows with `entityName = "contact"` —
|
||||
* routing here therefore covers both the CRM-contacts and the generic
|
||||
* Rows hot-paths in a single producer location.
|
||||
*
|
||||
* Resilience: outbox publish failures are caught and logged rather
|
||||
* than re-thrown. A row CUD must NOT roll back because the downstream
|
||||
* indexing event could not be queued — the failure is observable in
|
||||
* logs and can be corrected with a targeted reindex. Moving these emits
|
||||
* into the surrounding row mutation transactions is a separate hardening
|
||||
* slice.
|
||||
*/
|
||||
|
||||
import {
|
||||
crmContactsIndex,
|
||||
publishSearchDeleteEvent,
|
||||
publishSearchUpsertEvent,
|
||||
rowsIndex,
|
||||
type SearchIndexSchema,
|
||||
} from "@toolctrl/search";
|
||||
import type { Prisma, Row } from "@toolctrl/db/prisma-browser";
|
||||
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
|
||||
import { db } from "~/utils/db.server";
|
||||
import RowValueHelper from "~/utils/helpers/RowValueHelper";
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import type { RowValueWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
|
||||
/**
|
||||
* Minimum shape the search-outbox emitters need from a row. Both
|
||||
* `RowWithDetails` (apps/web) and `LegacyRowWithDetails` (@toolctrl/db)
|
||||
* conform to it — we widen here so the helper can be called from either
|
||||
* code-path without re-shaping the row.
|
||||
*/
|
||||
export type RowForSearch = Row & {
|
||||
values: RowValueWithDetails[];
|
||||
};
|
||||
|
||||
export interface RowSearchLogger {
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface EmitRowSearchUpsertInput {
|
||||
tenantId: string | null | undefined;
|
||||
entity: EntityWithDetails;
|
||||
row: RowForSearch;
|
||||
logger?: RowSearchLogger;
|
||||
}
|
||||
|
||||
export interface EmitRowSearchDeleteInput {
|
||||
tenantId: string | null | undefined;
|
||||
entity: { name: string };
|
||||
rowId: string;
|
||||
logger?: RowSearchLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the search-index schemas that should receive a search event
|
||||
* for the given entity. `contact` rows publish to BOTH `crm-contacts`
|
||||
* (typed schema with structured fields) and `rows` (generic searchable
|
||||
* blob) so either route can serve the contact list. All other entities
|
||||
* publish only to `rows`.
|
||||
*/
|
||||
function indexSchemasForEntity(entityName: string): readonly SearchIndexSchema[] {
|
||||
if (entityName === "contact") return [crmContactsIndex, rowsIndex];
|
||||
return [rowsIndex];
|
||||
}
|
||||
|
||||
function buildSearchableBlob(entity: EntityWithDetails, row: RowForSearch): string {
|
||||
const parts: string[] = [];
|
||||
for (const property of entity.properties) {
|
||||
if (property.type !== PropertyType.TEXT && property.type !== PropertyType.SELECT) continue;
|
||||
try {
|
||||
const text = RowValueHelper.getText({ entity, row, name: property.name });
|
||||
if (typeof text === "string" && text.trim().length > 0) parts.push(text);
|
||||
} catch {
|
||||
// ignore — defensive, the row may not carry every property value
|
||||
}
|
||||
}
|
||||
return parts.join(" \n ");
|
||||
}
|
||||
|
||||
function rowToContactDocument(
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
const get = (name: string): string => {
|
||||
try {
|
||||
return RowValueHelper.getText({ entity, row, name }) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
firstName: get("firstName"),
|
||||
lastName: get("lastName"),
|
||||
email: get("email"),
|
||||
company: get("company"),
|
||||
jobTitle: get("jobTitle"),
|
||||
status: get("status"),
|
||||
ownerId: row.createdByUserId ?? "",
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToRowsDocument(
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
entityId: row.entityId,
|
||||
entityName: entity.name,
|
||||
folio: typeof row.folio === "number" ? row.folio : 0,
|
||||
searchable: buildSearchableBlob(entity, row),
|
||||
createdById: row.createdByUserId ?? "",
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocumentForSchema(
|
||||
schema: SearchIndexSchema,
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
if (schema.name === "crm-contacts") return rowToContactDocument(entity, row, tenantId);
|
||||
return rowToRowsDocument(entity, row, tenantId);
|
||||
}
|
||||
|
||||
export async function emitRowSearchUpsert(input: EmitRowSearchUpsertInput): Promise<void> {
|
||||
if (!input.tenantId) return; // non-tenant rows are not indexed
|
||||
const schemas = indexSchemasForEntity(input.entity.name);
|
||||
try {
|
||||
await db.$transaction(async (tx: unknown) => {
|
||||
for (const schema of schemas) {
|
||||
const document = buildDocumentForSchema(schema, input.entity, input.row, input.tenantId!);
|
||||
await publishSearchUpsertEvent({
|
||||
publish: async (event) =>
|
||||
publishOutboxEvent(tx as OutboxPublisherTransaction, {
|
||||
tenantId: event.tenantId,
|
||||
type: event.type,
|
||||
payload: event.payload as Prisma.InputJsonValue,
|
||||
dispatchKey: event.dispatchKey ?? undefined,
|
||||
}),
|
||||
schema,
|
||||
tenantId: input.tenantId!,
|
||||
entityName: schema.name === "crm-contacts" ? "crmcontact" : "row",
|
||||
document,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger?.warn?.(
|
||||
{
|
||||
component: "row-search-outbox",
|
||||
rowId: input.row.id,
|
||||
entityName: input.entity.name,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
"row search upsert outbox publish failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitRowSearchDelete(input: EmitRowSearchDeleteInput): Promise<void> {
|
||||
if (!input.tenantId) return;
|
||||
const schemas = indexSchemasForEntity(input.entity.name);
|
||||
try {
|
||||
await db.$transaction(async (tx: unknown) => {
|
||||
for (const schema of schemas) {
|
||||
await publishSearchDeleteEvent({
|
||||
publish: async (event) =>
|
||||
publishOutboxEvent(tx as OutboxPublisherTransaction, {
|
||||
tenantId: event.tenantId,
|
||||
type: event.type,
|
||||
payload: event.payload as Prisma.InputJsonValue,
|
||||
dispatchKey: event.dispatchKey ?? undefined,
|
||||
}),
|
||||
schema,
|
||||
tenantId: input.tenantId!,
|
||||
entityName: schema.name === "crm-contacts" ? "crmcontact" : "row",
|
||||
documentId: input.rowId,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger?.warn?.(
|
||||
{
|
||||
component: "row-search-outbox",
|
||||
rowId: input.rowId,
|
||||
entityName: input.entity.name,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
"row search delete outbox publish failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user