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

216 lines
7.5 KiB
TypeScript

/**
* 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"
);
}
}