@open-mercato/search 0.7.0 → 0.7.1-develop.7103.1.41ff100d93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/dist/indexer/search-indexer.js +66 -0
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/lib/presenter-enricher.js +71 -1
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +143 -0
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
- package/dist/modules/search/lib/entity-access.js +1 -43
- package/dist/modules/search/lib/entity-access.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +18 -1
- package/dist/service.js.map +2 -2
- package/dist/strategies/token.strategy.js +8 -2
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +6 -5
- package/src/__tests__/presenter-enricher.test.ts +234 -0
- package/src/__tests__/search-indexer-batch.test.ts +214 -0
- package/src/__tests__/service.test.ts +24 -0
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -0
- package/src/__tests__/workers.test.ts +46 -17
- package/src/indexer/search-indexer.ts +84 -0
- package/src/lib/presenter-enricher.ts +92 -1
- package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +190 -2
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +107 -0
- package/src/modules/search/lib/entity-access.ts +4 -130
- package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
- package/src/service.ts +37 -2
- package/src/strategies/token.strategy.ts +15 -2
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
3
|
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
createCompanyFixture,
|
|
6
|
+
createPersonFixture,
|
|
7
|
+
deleteEntityIfExists
|
|
8
|
+
} from "@open-mercato/core/helpers/integration/crmFixtures";
|
|
4
9
|
const DEFAULT_STRATEGIES = ["fulltext", "vector", "tokens"];
|
|
10
|
+
const CUSTOMER_ENTITY = "customers:customer_entity";
|
|
11
|
+
const PERSON_PROFILE = "customers:customer_person_profile";
|
|
12
|
+
const COMPANY_PROFILE = "customers:customer_company_profile";
|
|
13
|
+
function presenterTitle(result) {
|
|
14
|
+
const title = result.presenter?.title;
|
|
15
|
+
return typeof title === "string" && title.trim().length > 0 ? title.trim() : null;
|
|
16
|
+
}
|
|
17
|
+
async function searchResults(request, token, path) {
|
|
18
|
+
const response = await apiRequest(request, "GET", path, { token });
|
|
19
|
+
if (!response.ok()) return { ok: false, status: response.status(), results: [] };
|
|
20
|
+
const body = await readJsonSafe(response) ?? {};
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
status: response.status(),
|
|
24
|
+
results: Array.isArray(body.results) ? body.results : []
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function customerSearchPath(query, entityType) {
|
|
28
|
+
const params = new URLSearchParams({
|
|
29
|
+
q: query,
|
|
30
|
+
limit: "20",
|
|
31
|
+
strategies: "tokens",
|
|
32
|
+
entityTypes: entityType
|
|
33
|
+
});
|
|
34
|
+
return `/api/search/search?${params.toString()}`;
|
|
35
|
+
}
|
|
36
|
+
function globalSearchPath(query) {
|
|
37
|
+
const params = new URLSearchParams({ q: query, limit: "20" });
|
|
38
|
+
return `/api/search/search/global?${params.toString()}`;
|
|
39
|
+
}
|
|
40
|
+
function hasCanonicalNavigation(result, expectedPrefix) {
|
|
41
|
+
if (typeof result.url !== "string") return false;
|
|
42
|
+
if (!result.url.startsWith(`${expectedPrefix}/`)) return false;
|
|
43
|
+
const target = result.url.slice(expectedPrefix.length + 1);
|
|
44
|
+
return target.length > 0 && !/[/?#]/.test(target);
|
|
45
|
+
}
|
|
5
46
|
test.describe("TC-SEARCH-006: global search honors saved strategy config over URL override", () => {
|
|
6
47
|
test("persisted enabledStrategies wins over the strategies URL parameter", async ({ request }) => {
|
|
7
48
|
test.slow();
|
|
@@ -44,5 +85,107 @@ test.describe("TC-SEARCH-006: global search honors saved strategy config over UR
|
|
|
44
85
|
}
|
|
45
86
|
}
|
|
46
87
|
});
|
|
88
|
+
test("returns one navigable profile result per customer and no base-entity duplicate", async ({ request }) => {
|
|
89
|
+
test.slow();
|
|
90
|
+
test.setTimeout(12e4);
|
|
91
|
+
const stamp = Date.now();
|
|
92
|
+
const personName = `QASRCH006P${stamp}`;
|
|
93
|
+
const companyName = `QASRCH006C${stamp}`;
|
|
94
|
+
let token = null;
|
|
95
|
+
let originalStrategies = DEFAULT_STRATEGIES;
|
|
96
|
+
let personId = null;
|
|
97
|
+
let companyId = null;
|
|
98
|
+
let personGlobalResults = [];
|
|
99
|
+
let companyGlobalResults = [];
|
|
100
|
+
try {
|
|
101
|
+
token = await getAuthToken(request, "admin");
|
|
102
|
+
const currentRes = await apiRequest(request, "GET", "/api/search/settings/global-search", { token });
|
|
103
|
+
expect(currentRes.ok(), "GET global-search settings should succeed").toBeTruthy();
|
|
104
|
+
const current = await readJsonSafe(currentRes) ?? {};
|
|
105
|
+
originalStrategies = Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0 ? current.enabledStrategies : DEFAULT_STRATEGIES;
|
|
106
|
+
const updateRes = await apiRequest(request, "POST", "/api/search/settings/global-search", {
|
|
107
|
+
token,
|
|
108
|
+
data: { enabledStrategies: ["tokens"] }
|
|
109
|
+
});
|
|
110
|
+
expect(updateRes.status(), "POST global-search settings should return 200").toBe(200);
|
|
111
|
+
personId = await createPersonFixture(request, token, {
|
|
112
|
+
firstName: "QA",
|
|
113
|
+
lastName: `Search 006 ${stamp}`,
|
|
114
|
+
displayName: personName
|
|
115
|
+
});
|
|
116
|
+
companyId = await createCompanyFixture(request, token, companyName);
|
|
117
|
+
await expect.poll(
|
|
118
|
+
async () => {
|
|
119
|
+
const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] = await Promise.all([
|
|
120
|
+
searchResults(request, token, customerSearchPath(personName, CUSTOMER_ENTITY)),
|
|
121
|
+
searchResults(request, token, customerSearchPath(personName, PERSON_PROFILE)),
|
|
122
|
+
searchResults(request, token, customerSearchPath(companyName, CUSTOMER_ENTITY)),
|
|
123
|
+
searchResults(request, token, customerSearchPath(companyName, COMPANY_PROFILE)),
|
|
124
|
+
searchResults(request, token, globalSearchPath(personName)),
|
|
125
|
+
searchResults(request, token, globalSearchPath(companyName))
|
|
126
|
+
]);
|
|
127
|
+
const queries = [
|
|
128
|
+
["person-entity", personEntity],
|
|
129
|
+
["person-profile", personProfile],
|
|
130
|
+
["company-entity", companyEntity],
|
|
131
|
+
["company-profile", companyProfile],
|
|
132
|
+
["person-global", personGlobal],
|
|
133
|
+
["company-global", companyGlobal]
|
|
134
|
+
];
|
|
135
|
+
const failedQuery = queries.find(([, result]) => !result.ok);
|
|
136
|
+
if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`;
|
|
137
|
+
const indexedQueries = [
|
|
138
|
+
["person-profile", personProfile.results, personName, PERSON_PROFILE],
|
|
139
|
+
["company-profile", companyProfile.results, companyName, COMPANY_PROFILE]
|
|
140
|
+
];
|
|
141
|
+
for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {
|
|
142
|
+
const matches = results.filter(
|
|
143
|
+
(result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId
|
|
144
|
+
);
|
|
145
|
+
if (matches.length === 0) return `${label}:matches:0`;
|
|
146
|
+
}
|
|
147
|
+
const baseEntityQueries = [
|
|
148
|
+
["person-entity", personEntity.results, personName],
|
|
149
|
+
["company-entity", companyEntity.results, companyName]
|
|
150
|
+
];
|
|
151
|
+
for (const [label, results, expectedTitle] of baseEntityQueries) {
|
|
152
|
+
const matches = results.filter((result) => presenterTitle(result) === expectedTitle);
|
|
153
|
+
if (matches.length !== 0) return `${label}:matches:${matches.length}`;
|
|
154
|
+
}
|
|
155
|
+
personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName);
|
|
156
|
+
companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName);
|
|
157
|
+
if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`;
|
|
158
|
+
if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`;
|
|
159
|
+
if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {
|
|
160
|
+
return `person-global:entity:${personGlobalResults[0]?.entityId ?? "missing"}`;
|
|
161
|
+
}
|
|
162
|
+
if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {
|
|
163
|
+
return `company-global:entity:${companyGlobalResults[0]?.entityId ?? "missing"}`;
|
|
164
|
+
}
|
|
165
|
+
if (!hasCanonicalNavigation(personGlobalResults[0], "/backend/customers/people-v2")) {
|
|
166
|
+
return `person-global:navigation:${personGlobalResults[0]?.url ?? "missing"}`;
|
|
167
|
+
}
|
|
168
|
+
if (!hasCanonicalNavigation(companyGlobalResults[0], "/backend/customers/companies-v2")) {
|
|
169
|
+
return `company-global:navigation:${companyGlobalResults[0]?.url ?? "missing"}`;
|
|
170
|
+
}
|
|
171
|
+
return "ready";
|
|
172
|
+
},
|
|
173
|
+
{ timeout: 1e4 }
|
|
174
|
+
).toBe("ready");
|
|
175
|
+
expect(personGlobalResults).toHaveLength(1);
|
|
176
|
+
expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE);
|
|
177
|
+
expect(companyGlobalResults).toHaveLength(1);
|
|
178
|
+
expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE);
|
|
179
|
+
} finally {
|
|
180
|
+
await deleteEntityIfExists(request, token, "/api/customers/people", personId);
|
|
181
|
+
await deleteEntityIfExists(request, token, "/api/customers/companies", companyId);
|
|
182
|
+
if (token && originalStrategies) {
|
|
183
|
+
await apiRequest(request, "POST", "/api/search/settings/global-search", {
|
|
184
|
+
token,
|
|
185
|
+
data: { enabledStrategies: originalStrategies }
|
|
186
|
+
}).catch(() => void 0);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
47
190
|
});
|
|
48
191
|
//# sourceMappingURL=TC-SEARCH-006.spec.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-006.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype GlobalSearchSettings = { enabledStrategies?: string[] }\ntype GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }\ntype GlobalSearchResponse = { strategiesEnabled?: string[] }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a\n * URL override. Source: issue #2483.\n *\n * Routes:\n * - GET/POST /api/search/settings/global-search (POST requires search.manage)\n * - GET /api/search/search/global (ignores any `strategies` URL param)\n *\n * Saves enabledStrategies = ['tokens'], then calls global search with a\n * conflicting ?strategies=fulltext,vector and asserts the response's\n * strategiesEnabled reflects the SAVED config, not the URL. The original config\n * is restored in `finally`. `admin` holds both search.view and search.manage.\n */\ntest.describe('TC-SEARCH-006: global search honors saved strategy config over URL override', () => {\n test('persisted enabledStrategies wins over the strategies URL parameter', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n expect(Array.isArray(current.enabledStrategies), 'settings expose an enabledStrategies array').toBe(true)\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n const updated = (await readJsonSafe<GlobalSearchUpdate>(updateRes)) ?? {}\n expect(updated.ok, 'update reports ok').toBe(true)\n expect(updated.enabledStrategies, 'update echoes the saved strategies').toEqual(['tokens'])\n\n const globalRes = await apiRequest(\n request,\n 'GET',\n `/api/search/search/global?q=qa-search-006-${Date.now()}&strategies=fulltext,vector`,\n { token },\n )\n expect(globalRes.ok(), 'GET global search should succeed').toBeTruthy()\n const globalBody = (await readJsonSafe<GlobalSearchResponse>(globalRes)) ?? {}\n expect(\n globalBody.strategiesEnabled,\n 'global search must use the saved config (tokens), ignoring the strategies URL override',\n ).toEqual(['tokens'])\n } finally {\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,
|
|
4
|
+
"sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n createCompanyFixture,\n createPersonFixture,\n deleteEntityIfExists,\n} from '@open-mercato/core/helpers/integration/crmFixtures'\n\ntype GlobalSearchSettings = { enabledStrategies?: string[] }\ntype GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }\ntype SearchResultItem = {\n entityId?: string\n recordId?: string\n presenter?: { title?: string } | null\n url?: string | null\n}\ntype GlobalSearchResponse = { strategiesEnabled?: string[]; results?: SearchResultItem[] }\ntype SearchQueryResult = { ok: boolean; status: number; results: SearchResultItem[] }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\nconst CUSTOMER_ENTITY = 'customers:customer_entity'\nconst PERSON_PROFILE = 'customers:customer_person_profile'\nconst COMPANY_PROFILE = 'customers:customer_company_profile'\n\nfunction presenterTitle(result: SearchResultItem): string | null {\n const title = result.presenter?.title\n return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null\n}\n\nasync function searchResults(\n request: APIRequestContext,\n token: string,\n path: string,\n): Promise<SearchQueryResult> {\n const response = await apiRequest(request, 'GET', path, { token })\n if (!response.ok()) return { ok: false, status: response.status(), results: [] }\n const body = (await readJsonSafe<GlobalSearchResponse>(response)) ?? {}\n return {\n ok: true,\n status: response.status(),\n results: Array.isArray(body.results) ? body.results : [],\n }\n}\n\nfunction customerSearchPath(query: string, entityType: string): string {\n const params = new URLSearchParams({\n q: query,\n limit: '20',\n strategies: 'tokens',\n entityTypes: entityType,\n })\n return `/api/search/search?${params.toString()}`\n}\n\nfunction globalSearchPath(query: string): string {\n const params = new URLSearchParams({ q: query, limit: '20' })\n return `/api/search/search/global?${params.toString()}`\n}\n\n/**\n * A profile result navigates to the customer's v2 detail page, whose path segment is the base\n * customer entity id \u2014 not the profile's own `recordId`. So this asserts the shape of a direct\n * detail link (prefix + one non-empty id segment, no query string or anchor) rather than\n * equality with `recordId`.\n */\nfunction hasCanonicalNavigation(result: SearchResultItem, expectedPrefix: string): boolean {\n if (typeof result.url !== 'string') return false\n if (!result.url.startsWith(`${expectedPrefix}/`)) return false\n const target = result.url.slice(expectedPrefix.length + 1)\n return target.length > 0 && !/[/?#]/.test(target)\n}\n\n/**\n * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a\n * URL override. Source: issue #2483.\n *\n * Routes:\n * - GET/POST /api/search/settings/global-search (POST requires search.manage)\n * - GET /api/search/search/global (ignores any `strategies` URL param)\n *\n * Saves enabledStrategies = ['tokens'], then calls global search with a\n * conflicting ?strategies=fulltext,vector and asserts the response's\n * strategiesEnabled reflects the SAVED config, not the URL. The original config\n * is restored in `finally`. `admin` holds both search.view and search.manage.\n */\ntest.describe('TC-SEARCH-006: global search honors saved strategy config over URL override', () => {\n test('persisted enabledStrategies wins over the strategies URL parameter', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n expect(Array.isArray(current.enabledStrategies), 'settings expose an enabledStrategies array').toBe(true)\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n const updated = (await readJsonSafe<GlobalSearchUpdate>(updateRes)) ?? {}\n expect(updated.ok, 'update reports ok').toBe(true)\n expect(updated.enabledStrategies, 'update echoes the saved strategies').toEqual(['tokens'])\n\n const globalRes = await apiRequest(\n request,\n 'GET',\n `/api/search/search/global?q=qa-search-006-${Date.now()}&strategies=fulltext,vector`,\n { token },\n )\n expect(globalRes.ok(), 'GET global search should succeed').toBeTruthy()\n const globalBody = (await readJsonSafe<GlobalSearchResponse>(globalRes)) ?? {}\n expect(\n globalBody.strategiesEnabled,\n 'global search must use the saved config (tokens), ignoring the strategies URL override',\n ).toEqual(['tokens'])\n } finally {\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n\n test('returns one navigable profile result per customer and no base-entity duplicate', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n const stamp = Date.now()\n const personName = `QASRCH006P${stamp}`\n const companyName = `QASRCH006C${stamp}`\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n let personId: string | null = null\n let companyId: string | null = null\n let personGlobalResults: SearchResultItem[] = []\n let companyGlobalResults: SearchResultItem[] = []\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n\n personId = await createPersonFixture(request, token, {\n firstName: 'QA',\n lastName: `Search 006 ${stamp}`,\n displayName: personName,\n })\n companyId = await createCompanyFixture(request, token, companyName)\n\n await expect\n .poll(\n async () => {\n const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] =\n await Promise.all([\n searchResults(request, token!, customerSearchPath(personName, CUSTOMER_ENTITY)),\n searchResults(request, token!, customerSearchPath(personName, PERSON_PROFILE)),\n searchResults(request, token!, customerSearchPath(companyName, CUSTOMER_ENTITY)),\n searchResults(request, token!, customerSearchPath(companyName, COMPANY_PROFILE)),\n searchResults(request, token!, globalSearchPath(personName)),\n searchResults(request, token!, globalSearchPath(companyName)),\n ])\n\n const queries = [\n ['person-entity', personEntity],\n ['person-profile', personProfile],\n ['company-entity', companyEntity],\n ['company-profile', companyProfile],\n ['person-global', personGlobal],\n ['company-global', companyGlobal],\n ] as const\n const failedQuery = queries.find(([, result]) => !result.ok)\n if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`\n\n const indexedQueries = [\n ['person-profile', personProfile.results, personName, PERSON_PROFILE],\n ['company-profile', companyProfile.results, companyName, COMPANY_PROFILE],\n ] as const\n for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {\n const matches = results.filter(\n (result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId,\n )\n if (matches.length === 0) return `${label}:matches:0`\n }\n\n // Under the default OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=false the token strategy\n // refuses to return base customer rows, so an explicit query for that entity type is\n // empty even though the same customers' profiles are already indexed above. (The rows\n // themselves stay in search_tokens \u2014 the list-search id lookup still needs them.)\n const baseEntityQueries = [\n ['person-entity', personEntity.results, personName],\n ['company-entity', companyEntity.results, companyName],\n ] as const\n for (const [label, results, expectedTitle] of baseEntityQueries) {\n const matches = results.filter((result) => presenterTitle(result) === expectedTitle)\n if (matches.length !== 0) return `${label}:matches:${matches.length}`\n }\n\n personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName)\n companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName)\n if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`\n if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`\n if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {\n return `person-global:entity:${personGlobalResults[0]?.entityId ?? 'missing'}`\n }\n if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {\n return `company-global:entity:${companyGlobalResults[0]?.entityId ?? 'missing'}`\n }\n if (!hasCanonicalNavigation(personGlobalResults[0], '/backend/customers/people-v2')) {\n return `person-global:navigation:${personGlobalResults[0]?.url ?? 'missing'}`\n }\n if (!hasCanonicalNavigation(companyGlobalResults[0], '/backend/customers/companies-v2')) {\n return `company-global:navigation:${companyGlobalResults[0]?.url ?? 'missing'}`\n }\n\n return 'ready'\n },\n { timeout: 10_000 },\n )\n .toBe('ready')\n\n expect(personGlobalResults).toHaveLength(1)\n expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE)\n expect(companyGlobalResults).toHaveLength(1)\n expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE)\n } finally {\n await deleteEntityIfExists(request, token, '/api/customers/people', personId)\n await deleteEntityIfExists(request, token, '/api/customers/companies', companyId)\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,44 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
function canReadSearchEntity(entityId, lookup, subject, options = {}) {
|
|
3
|
-
if (subject.isSuperAdmin) return true;
|
|
4
|
-
const config = lookup.getEntityConfig(entityId);
|
|
5
|
-
if (!config) {
|
|
6
|
-
options.onDeny?.(entityId, "unconfigured");
|
|
7
|
-
return false;
|
|
8
|
-
}
|
|
9
|
-
const required = config.aclFeatures;
|
|
10
|
-
if (!required || required.length === 0) {
|
|
11
|
-
options.onDeny?.(entityId, "no-acl-features");
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
const allowed = authorizeFeatures(required, {
|
|
15
|
-
grantedFeatures: subject.grantedFeatures,
|
|
16
|
-
unrestricted: false
|
|
17
|
-
});
|
|
18
|
-
if (!allowed) options.onDeny?.(entityId, "insufficient-features");
|
|
19
|
-
return allowed;
|
|
20
|
-
}
|
|
21
|
-
function resolveReadableEntityTypes(lookup, subject, requestedEntityTypes) {
|
|
22
|
-
if (subject.isSuperAdmin) return requestedEntityTypes;
|
|
23
|
-
const readable = lookup.getAllEntityConfigs().filter((config) => config.enabled !== false).map((config) => config.entityId).filter((entityId) => canReadSearchEntity(entityId, lookup, subject));
|
|
24
|
-
if (!requestedEntityTypes) return readable;
|
|
25
|
-
const requested = new Set(requestedEntityTypes);
|
|
26
|
-
return readable.filter((entityId) => requested.has(entityId));
|
|
27
|
-
}
|
|
28
|
-
function filterSearchResultsByEntityAccess(results, lookup, subject, options = {}) {
|
|
29
|
-
if (subject.isSuperAdmin) return [...results];
|
|
30
|
-
const decisions = /* @__PURE__ */ new Map();
|
|
31
|
-
return results.filter((result) => {
|
|
32
|
-
const cached = decisions.get(result.entityId);
|
|
33
|
-
if (cached !== void 0) return cached;
|
|
34
|
-
const allowed = canReadSearchEntity(result.entityId, lookup, subject, options);
|
|
35
|
-
decisions.set(result.entityId, allowed);
|
|
36
|
-
return allowed;
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
export {
|
|
40
|
-
canReadSearchEntity,
|
|
41
|
-
filterSearchResultsByEntityAccess,
|
|
42
|
-
resolveReadableEntityTypes
|
|
43
|
-
};
|
|
1
|
+
export * from "@open-mercato/shared/lib/search/entityAccess";
|
|
44
2
|
//# sourceMappingURL=entity-access.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/lib/entity-access.ts"],
|
|
4
|
-
"sourcesContent": ["
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Re-export shared entity-access helpers so existing imports keep working.\n * The canonical implementation lives in @open-mercato/shared to be reusable\n * from ai-assistant without introducing a search\u2194ai-assistant dependency cycle.\n */\nexport * from '@open-mercato/shared/lib/search/entityAccess'\n"],
|
|
5
|
+
"mappings": "AAKA,cAAc;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -145,28 +145,11 @@ async function handleFulltextIndexJob(job, jobCtx, ctx) {
|
|
|
145
145
|
if (!searchIndexer) {
|
|
146
146
|
throw new Error("searchIndexer not available for batch indexing");
|
|
147
147
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
entityId,
|
|
154
|
-
recordId,
|
|
155
|
-
tenantId,
|
|
156
|
-
organizationId
|
|
157
|
-
});
|
|
158
|
-
if (result.action === "indexed") {
|
|
159
|
-
successCount++;
|
|
160
|
-
}
|
|
161
|
-
} catch (error) {
|
|
162
|
-
failCount++;
|
|
163
|
-
searchDebugWarn("fulltext-index.worker", "Failed to index record in batch", {
|
|
164
|
-
entityId,
|
|
165
|
-
recordId,
|
|
166
|
-
error: error instanceof Error ? error.message : error
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
}
|
|
148
|
+
const { indexed: successCount, skipped: skippedCount } = await searchIndexer.indexRecordsById({
|
|
149
|
+
items: records.map(({ entityId, recordId }) => ({ entityId, recordId })),
|
|
150
|
+
tenantId,
|
|
151
|
+
organizationId
|
|
152
|
+
});
|
|
170
153
|
await advanceFulltextReindexProgress({
|
|
171
154
|
db,
|
|
172
155
|
em,
|
|
@@ -180,7 +163,7 @@ async function handleFulltextIndexJob(job, jobCtx, ctx) {
|
|
|
180
163
|
tenantId,
|
|
181
164
|
requestedCount: records.length,
|
|
182
165
|
successCount,
|
|
183
|
-
|
|
166
|
+
skippedCount
|
|
184
167
|
});
|
|
185
168
|
await recordIndexerLog(
|
|
186
169
|
{ em: em ?? void 0 },
|
|
@@ -189,7 +172,7 @@ async function handleFulltextIndexJob(job, jobCtx, ctx) {
|
|
|
189
172
|
handler: "worker:fulltext:batch-index",
|
|
190
173
|
message: `Indexed ${successCount}/${records.length} records to fulltext`,
|
|
191
174
|
tenantId,
|
|
192
|
-
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount,
|
|
175
|
+
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, skippedCount }
|
|
193
176
|
}
|
|
194
177
|
);
|
|
195
178
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/workers/fulltext-index.worker.ts"],
|
|
4
|
-
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { Kysely } from 'kysely'\nimport { FULLTEXT_INDEXING_QUEUE_NAME, type FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport type { FullTextSearchStrategy } from '../../../strategies/fulltext.strategy'\nimport type { SearchIndexer } from '../../../indexer/search-indexer'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../lib/debug'\nimport { clearReindexLock, updateReindexProgress } from '../lib/reindex-lock'\nimport { hasActiveReindexProgress, incrementReindexProgress } from '../lib/reindex-progress'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 2\nconst envConcurrency = process.env.WORKERS_FULLTEXT_INDEXING_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: FULLTEXT_INDEXING_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nasync function advanceFulltextReindexProgress(params: {\n db: Kysely<any> | null\n em: EntityManager | null\n progressService: ProgressService | null\n tenantId: string\n organizationId?: string | null\n delta: number\n}): Promise<void> {\n if (!Number.isFinite(params.delta) || params.delta <= 0) return\n\n if (params.progressService && params.em) {\n const hasActiveProgress = await hasActiveReindexProgress({\n em: params.em,\n type: 'fulltext',\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n })\n\n if (!hasActiveProgress) {\n if (params.db) {\n await clearReindexLock(params.db, params.tenantId, 'fulltext', params.organizationId ?? null)\n }\n return\n }\n\n if (params.db) {\n await updateReindexProgress(params.db, params.tenantId, 'fulltext', params.delta, params.organizationId ?? null)\n }\n\n const completed = await incrementReindexProgress({\n em: params.em,\n progressService: params.progressService,\n type: 'fulltext',\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n delta: params.delta,\n })\n if (completed && params.db) {\n await clearReindexLock(params.db, params.tenantId, 'fulltext', params.organizationId ?? null)\n }\n return\n }\n\n if (params.db) {\n await updateReindexProgress(params.db, params.tenantId, 'fulltext', params.delta, params.organizationId ?? null)\n }\n}\n\n/**\n * Process a fulltext indexing job.\n *\n * This handler processes single record indexing, batch indexing, deletion, and purge\n * operations for the fulltext search strategy.\n *\n * All indexing operations (single and batch) use searchIndexer.indexRecordById() to load\n * fresh data, ensuring consistency with the vector worker pattern.\n *\n * @param job - The queued job containing payload\n * @param jobCtx - Queue job context with job ID and attempt info\n * @param ctx - DI container context for resolving services\n */\nexport async function handleFulltextIndexJob(\n job: QueuedJob<FulltextIndexJobPayload>,\n jobCtx: JobContext,\n ctx: HandlerContext,\n): Promise<void> {\n const { jobType, tenantId } = job.payload\n\n if (!tenantId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping job with missing tenantId', {\n jobId: jobCtx.jobId,\n jobType,\n })\n return\n }\n\n // Resolve EntityManager for logging and Kysely for database queries\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n let db: Kysely<any> | null = null\n try {\n em = ctx.resolve('em') as EntityManager\n db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\n } catch {\n em = null\n db = null\n }\n\n // Resolve searchIndexer for loading fresh data\n let searchIndexer: SearchIndexer | undefined\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('fulltext-index.worker', 'searchIndexer not available')\n }\n\n // Resolve fulltext strategy\n let fulltextStrategy: FullTextSearchStrategy | undefined\n try {\n const searchStrategies = ctx.resolve<unknown[]>('searchStrategies')\n fulltextStrategy = searchStrategies?.find(\n (s: unknown) => (s as { id?: string })?.id === 'fulltext',\n ) as FullTextSearchStrategy | undefined\n } catch {\n searchDebugWarn('fulltext-index.worker', 'searchStrategies not available')\n return\n }\n\n if (!fulltextStrategy) {\n searchDebugWarn('fulltext-index.worker', 'Fulltext strategy not configured')\n return\n }\n\n // Check if fulltext is available\n const isAvailable = await fulltextStrategy.isAvailable()\n if (!isAvailable) {\n throw new Error('Fulltext search is not available') // Will trigger retry\n }\n\n try {\n let progressService: ProgressService | null = null\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n } catch {\n progressService = null\n }\n\n // ========== SINGLE INDEX: Use searchIndexer.indexRecordById() for fresh data ==========\n if (jobType === 'index') {\n const { entityType, recordId, organizationId } = job.payload as {\n entityType: string\n recordId: string\n organizationId?: string | null\n }\n\n if (!entityType || !recordId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping index with missing fields', {\n jobId: jobCtx.jobId,\n entityType,\n recordId,\n })\n return\n }\n\n if (!searchIndexer) {\n throw new Error('searchIndexer not available for single-record index')\n }\n\n const result = await searchIndexer.indexRecordById({\n entityId: entityType as EntityId,\n recordId,\n tenantId,\n organizationId,\n })\n\n searchDebug('fulltext-index.worker', 'Indexed single record to fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityType,\n recordId,\n action: result.action,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:index',\n message: `Indexed record to fulltext (${result.action})`,\n entityType,\n recordId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n\n // ========== BATCH-INDEX: Use searchIndexer.indexRecordById() for fresh data ==========\n if (jobType === 'batch-index') {\n const { records, organizationId } = job.payload\n if (!records || records.length === 0) {\n searchDebugWarn('fulltext-index.worker', 'Skipping batch-index with no records', {\n jobId: jobCtx.jobId,\n })\n return\n }\n\n if (!searchIndexer) {\n throw new Error('searchIndexer not available for batch indexing')\n }\n\n // Process each record using indexRecordById (same pattern as vector worker)\n let successCount = 0\n let failCount = 0\n\n for (const { entityId, recordId } of records) {\n try {\n const result = await searchIndexer.indexRecordById({\n entityId: entityId as EntityId,\n recordId,\n tenantId,\n organizationId,\n })\n if (result.action === 'indexed') {\n successCount++\n }\n } catch (error) {\n failCount++\n searchDebugWarn('fulltext-index.worker', 'Failed to index record in batch', {\n entityId,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n }\n }\n\n await advanceFulltextReindexProgress({\n db,\n em,\n progressService,\n tenantId,\n organizationId: organizationId ?? null,\n delta: records.length,\n })\n\n searchDebug('fulltext-index.worker', 'Batch indexed to fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n requestedCount: records.length,\n successCount,\n failCount,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:batch-index',\n message: `Indexed ${successCount}/${records.length} records to fulltext`,\n tenantId,\n details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, failCount },\n },\n )\n return\n }\n\n // ========== DELETE ==========\n if (jobType === 'delete') {\n const { entityId, recordId } = job.payload\n if (!entityId || !recordId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping delete with missing fields', {\n jobId: jobCtx.jobId,\n entityId,\n recordId,\n })\n return\n }\n\n await fulltextStrategy.delete(entityId, recordId, tenantId)\n\n searchDebug('fulltext-index.worker', 'Deleted from fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityId,\n recordId,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:delete',\n message: `Deleted record from fulltext`,\n entityType: entityId,\n recordId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n\n // ========== PURGE ==========\n if (jobType === 'purge') {\n const { entityId } = job.payload\n if (!entityId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping purge with missing entityId', {\n jobId: jobCtx.jobId,\n })\n return\n }\n\n await fulltextStrategy.purge(entityId, tenantId)\n\n searchDebug('fulltext-index.worker', 'Purged entity from fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityId,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:purge',\n message: `Purged entity from fulltext`,\n entityType: entityId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n } catch (error) {\n searchError('fulltext-index.worker', `Failed to ${jobType}`, {\n jobId: jobCtx.jobId,\n tenantId,\n error: error instanceof Error ? error.message : error,\n attemptNumber: jobCtx.attemptNumber,\n })\n\n const entityId = 'entityId' in job.payload ? job.payload.entityId :\n 'entityType' in job.payload ? (job.payload as { entityType?: string }).entityType : undefined\n const recordId = 'recordId' in job.payload ? job.payload.recordId : undefined\n\n await recordIndexerError(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: `worker:fulltext:${jobType}`,\n error,\n entityType: entityId,\n recordId,\n tenantId,\n payload: job.payload,\n },\n )\n\n // Re-throw to let the queue handle retry logic\n throw error\n }\n}\n\n/**\n * Default export for worker auto-discovery.\n * Wraps handleFulltextIndexJob to match the expected handler signature.\n */\nexport default async function handle(\n job: QueuedJob<FulltextIndexJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n return handleFulltextIndexJob(job, ctx, ctx)\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,oCAAkE;AAM3E,SAAS,wBAAwB;AACjC,SAAS,0BAA0B;AAEnC,SAAS,aAAa,iBAAiB,mBAAmB;AAC1D,SAAS,kBAAkB,6BAA6B;AACxD,SAAS,0BAA0B,gCAAgC;AAGnE,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAIA,eAAe,+BAA+B,QAO5B;AAChB,MAAI,CAAC,OAAO,SAAS,OAAO,KAAK,KAAK,OAAO,SAAS,EAAG;AAEzD,MAAI,OAAO,mBAAmB,OAAO,IAAI;AACvC,UAAM,oBAAoB,MAAM,yBAAyB;AAAA,MACvD,IAAI,OAAO;AAAA,MACX,MAAM;AAAA,MACN,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO,kBAAkB;AAAA,IAC3C,CAAC;AAED,QAAI,CAAC,mBAAmB;AACtB,UAAI,OAAO,IAAI;AACb,cAAM,iBAAiB,OAAO,IAAI,OAAO,UAAU,YAAY,OAAO,kBAAkB,IAAI;AAAA,MAC9F;AACA;AAAA,IACF;AAEA,QAAI,OAAO,IAAI;AACb,YAAM,sBAAsB,OAAO,IAAI,OAAO,UAAU,YAAY,OAAO,OAAO,OAAO,kBAAkB,IAAI;AAAA,IACjH;AAEA,UAAM,YAAY,MAAM,yBAAyB;AAAA,MAC/C,IAAI,OAAO;AAAA,MACX,iBAAiB,OAAO;AAAA,MACxB,MAAM;AAAA,MACN,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO,kBAAkB;AAAA,MACzC,OAAO,OAAO;AAAA,IAChB,CAAC;AACD,QAAI,aAAa,OAAO,IAAI;AAC1B,YAAM,iBAAiB,OAAO,IAAI,OAAO,UAAU,YAAY,OAAO,kBAAkB,IAAI;AAAA,IAC9F;AACA;AAAA,EACF;AAEA,MAAI,OAAO,IAAI;AACb,UAAM,sBAAsB,OAAO,IAAI,OAAO,UAAU,YAAY,OAAO,OAAO,OAAO,kBAAkB,IAAI;AAAA,EACjH;AACF;
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { Kysely } from 'kysely'\nimport { FULLTEXT_INDEXING_QUEUE_NAME, type FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport type { FullTextSearchStrategy } from '../../../strategies/fulltext.strategy'\nimport type { SearchIndexer } from '../../../indexer/search-indexer'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../lib/debug'\nimport { clearReindexLock, updateReindexProgress } from '../lib/reindex-lock'\nimport { hasActiveReindexProgress, incrementReindexProgress } from '../lib/reindex-progress'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 2\nconst envConcurrency = process.env.WORKERS_FULLTEXT_INDEXING_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: FULLTEXT_INDEXING_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nasync function advanceFulltextReindexProgress(params: {\n db: Kysely<any> | null\n em: EntityManager | null\n progressService: ProgressService | null\n tenantId: string\n organizationId?: string | null\n delta: number\n}): Promise<void> {\n if (!Number.isFinite(params.delta) || params.delta <= 0) return\n\n if (params.progressService && params.em) {\n const hasActiveProgress = await hasActiveReindexProgress({\n em: params.em,\n type: 'fulltext',\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n })\n\n if (!hasActiveProgress) {\n if (params.db) {\n await clearReindexLock(params.db, params.tenantId, 'fulltext', params.organizationId ?? null)\n }\n return\n }\n\n if (params.db) {\n await updateReindexProgress(params.db, params.tenantId, 'fulltext', params.delta, params.organizationId ?? null)\n }\n\n const completed = await incrementReindexProgress({\n em: params.em,\n progressService: params.progressService,\n type: 'fulltext',\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n delta: params.delta,\n })\n if (completed && params.db) {\n await clearReindexLock(params.db, params.tenantId, 'fulltext', params.organizationId ?? null)\n }\n return\n }\n\n if (params.db) {\n await updateReindexProgress(params.db, params.tenantId, 'fulltext', params.delta, params.organizationId ?? null)\n }\n}\n\n/**\n * Process a fulltext indexing job.\n *\n * This handler processes single record indexing, batch indexing, deletion, and purge\n * operations for the fulltext search strategy.\n *\n * Single-record jobs load fresh data via searchIndexer.indexRecordById(). Batch jobs load\n * fresh data per record via searchIndexer.indexRecordsById(), which flushes the whole batch\n * through a single bulk write instead of one write per record.\n *\n * @param job - The queued job containing payload\n * @param jobCtx - Queue job context with job ID and attempt info\n * @param ctx - DI container context for resolving services\n */\nexport async function handleFulltextIndexJob(\n job: QueuedJob<FulltextIndexJobPayload>,\n jobCtx: JobContext,\n ctx: HandlerContext,\n): Promise<void> {\n const { jobType, tenantId } = job.payload\n\n if (!tenantId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping job with missing tenantId', {\n jobId: jobCtx.jobId,\n jobType,\n })\n return\n }\n\n // Resolve EntityManager for logging and Kysely for database queries\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n let db: Kysely<any> | null = null\n try {\n em = ctx.resolve('em') as EntityManager\n db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\n } catch {\n em = null\n db = null\n }\n\n // Resolve searchIndexer for loading fresh data\n let searchIndexer: SearchIndexer | undefined\n try {\n searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchDebugWarn('fulltext-index.worker', 'searchIndexer not available')\n }\n\n // Resolve fulltext strategy\n let fulltextStrategy: FullTextSearchStrategy | undefined\n try {\n const searchStrategies = ctx.resolve<unknown[]>('searchStrategies')\n fulltextStrategy = searchStrategies?.find(\n (s: unknown) => (s as { id?: string })?.id === 'fulltext',\n ) as FullTextSearchStrategy | undefined\n } catch {\n searchDebugWarn('fulltext-index.worker', 'searchStrategies not available')\n return\n }\n\n if (!fulltextStrategy) {\n searchDebugWarn('fulltext-index.worker', 'Fulltext strategy not configured')\n return\n }\n\n // Check if fulltext is available\n const isAvailable = await fulltextStrategy.isAvailable()\n if (!isAvailable) {\n throw new Error('Fulltext search is not available') // Will trigger retry\n }\n\n try {\n let progressService: ProgressService | null = null\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n } catch {\n progressService = null\n }\n\n // ========== SINGLE INDEX: Use searchIndexer.indexRecordById() for fresh data ==========\n if (jobType === 'index') {\n const { entityType, recordId, organizationId } = job.payload as {\n entityType: string\n recordId: string\n organizationId?: string | null\n }\n\n if (!entityType || !recordId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping index with missing fields', {\n jobId: jobCtx.jobId,\n entityType,\n recordId,\n })\n return\n }\n\n if (!searchIndexer) {\n throw new Error('searchIndexer not available for single-record index')\n }\n\n const result = await searchIndexer.indexRecordById({\n entityId: entityType as EntityId,\n recordId,\n tenantId,\n organizationId,\n })\n\n searchDebug('fulltext-index.worker', 'Indexed single record to fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityType,\n recordId,\n action: result.action,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:index',\n message: `Indexed record to fulltext (${result.action})`,\n entityType,\n recordId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n\n // ========== BATCH-INDEX: Load fresh data, write the whole batch in one call ==========\n if (jobType === 'batch-index') {\n const { records, organizationId } = job.payload\n if (!records || records.length === 0) {\n searchDebugWarn('fulltext-index.worker', 'Skipping batch-index with no records', {\n jobId: jobCtx.jobId,\n })\n return\n }\n\n if (!searchIndexer) {\n throw new Error('searchIndexer not available for batch indexing')\n }\n\n // Load and index the whole batch through a single bulk write instead of\n // one indexRecordById() call per record.\n const { indexed: successCount, skipped: skippedCount } = await searchIndexer.indexRecordsById({\n items: records.map(({ entityId, recordId }) => ({ entityId: entityId as EntityId, recordId })),\n tenantId,\n organizationId,\n })\n\n await advanceFulltextReindexProgress({\n db,\n em,\n progressService,\n tenantId,\n organizationId: organizationId ?? null,\n delta: records.length,\n })\n\n searchDebug('fulltext-index.worker', 'Batch indexed to fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n requestedCount: records.length,\n successCount,\n skippedCount,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:batch-index',\n message: `Indexed ${successCount}/${records.length} records to fulltext`,\n tenantId,\n details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, skippedCount },\n },\n )\n return\n }\n\n // ========== DELETE ==========\n if (jobType === 'delete') {\n const { entityId, recordId } = job.payload\n if (!entityId || !recordId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping delete with missing fields', {\n jobId: jobCtx.jobId,\n entityId,\n recordId,\n })\n return\n }\n\n await fulltextStrategy.delete(entityId, recordId, tenantId)\n\n searchDebug('fulltext-index.worker', 'Deleted from fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityId,\n recordId,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:delete',\n message: `Deleted record from fulltext`,\n entityType: entityId,\n recordId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n\n // ========== PURGE ==========\n if (jobType === 'purge') {\n const { entityId } = job.payload\n if (!entityId) {\n searchDebugWarn('fulltext-index.worker', 'Skipping purge with missing entityId', {\n jobId: jobCtx.jobId,\n })\n return\n }\n\n await fulltextStrategy.purge(entityId, tenantId)\n\n searchDebug('fulltext-index.worker', 'Purged entity from fulltext', {\n jobId: jobCtx.jobId,\n tenantId,\n entityId,\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: 'worker:fulltext:purge',\n message: `Purged entity from fulltext`,\n entityType: entityId,\n tenantId,\n details: { jobId: jobCtx.jobId },\n },\n )\n return\n }\n } catch (error) {\n searchError('fulltext-index.worker', `Failed to ${jobType}`, {\n jobId: jobCtx.jobId,\n tenantId,\n error: error instanceof Error ? error.message : error,\n attemptNumber: jobCtx.attemptNumber,\n })\n\n const entityId = 'entityId' in job.payload ? job.payload.entityId :\n 'entityType' in job.payload ? (job.payload as { entityType?: string }).entityType : undefined\n const recordId = 'recordId' in job.payload ? job.payload.recordId : undefined\n\n await recordIndexerError(\n { em: em ?? undefined },\n {\n source: 'fulltext',\n handler: `worker:fulltext:${jobType}`,\n error,\n entityType: entityId,\n recordId,\n tenantId,\n payload: job.payload,\n },\n )\n\n // Re-throw to let the queue handle retry logic\n throw error\n }\n}\n\n/**\n * Default export for worker auto-discovery.\n * Wraps handleFulltextIndexJob to match the expected handler signature.\n */\nexport default async function handle(\n job: QueuedJob<FulltextIndexJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n return handleFulltextIndexJob(job, ctx, ctx)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/service.js
CHANGED
|
@@ -4,6 +4,19 @@ const DEFAULT_MERGE_CONFIG = {
|
|
|
4
4
|
duplicateHandling: "highest_score"
|
|
5
5
|
};
|
|
6
6
|
const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2e3;
|
|
7
|
+
const BULK_INDEX_FALLBACK_CONCURRENCY = 4;
|
|
8
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
9
|
+
if (items.length === 0) return;
|
|
10
|
+
let nextIndex = 0;
|
|
11
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
12
|
+
for (; ; ) {
|
|
13
|
+
const currentIndex = nextIndex++;
|
|
14
|
+
if (currentIndex >= items.length) return;
|
|
15
|
+
await worker(items[currentIndex]);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
await Promise.all(runners);
|
|
19
|
+
}
|
|
7
20
|
function normalizeOrganizationFilter(options) {
|
|
8
21
|
const single = typeof options.organizationId === "string" ? options.organizationId.trim() : "";
|
|
9
22
|
if (single) return [single];
|
|
@@ -145,7 +158,11 @@ class SearchService {
|
|
|
145
158
|
if (strategy.bulkIndex) {
|
|
146
159
|
return strategy.bulkIndex(records);
|
|
147
160
|
}
|
|
148
|
-
return
|
|
161
|
+
return mapWithConcurrency(
|
|
162
|
+
records,
|
|
163
|
+
BULK_INDEX_FALLBACK_CONCURRENCY,
|
|
164
|
+
(record) => this.executeStrategyIndex(strategy, record)
|
|
165
|
+
);
|
|
149
166
|
})
|
|
150
167
|
);
|
|
151
168
|
const failures = [];
|
package/dist/service.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/service.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Recompute configured presenters at request time and fill missing presenter\n * or navigation data for unconfigured results.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n this.throwOnStrategyFailures('index', strategies, results, {\n entityId: record.entityId,\n recordId: record.recordId,\n })\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n this.throwOnStrategyFailures('purge', strategies, results, { entityId })\n }\n\n /**\n * Inspect the settled results of a per-strategy write operation, log every\n * rejection, and re-throw an aggregated error when any strategy failed.\n *\n * Write operations (index/delete/purge) must surface failures to the caller\n * so the queue worker re-throws and the job is retried. Swallowing rejections\n * here causes silent, permanent index gaps on transient failures such as\n * Postgres connection-pool exhaustion (issue #3103). Successful strategies\n * still commit their work; only the aggregated failure propagates.\n */\n private throwOnStrategyFailures(\n operation: 'index' | 'delete' | 'purge',\n strategies: SearchStrategy[],\n results: PromiseSettledResult<unknown>[],\n context: { entityId: string; recordId?: string },\n ): void {\n const failures: Array<{ strategyId: string; reason: unknown }> = []\n\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })\n searchError('SearchService', `Strategy ${operation} failed`, {\n strategyId: strategy?.id,\n entityId: context.entityId,\n recordId: context.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n if (failures.length === 0) return\n\n const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures\n .map((failure) => {\n const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)\n return `${failure.strategyId} (${message})`\n })\n .join(', ')}`\n\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n summary,\n )\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAUA,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;AAK5B,MAAM,uBAA0C;AAAA,EAC9C,mBAAmB;AACrB;AAOA,MAAM,qCAAqC;
|
|
4
|
+
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * Cache TTL for strategy availability checks.\n * Short window so connectivity changes (Meilisearch up/down) propagate quickly,\n * long enough to skip per-request RTT to remote backends on hot paths.\n */\nconst STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000\n\n/**\n * Maximum records indexed at once when bulkIndex falls back to per-record writes\n * for a strategy that has no bulkIndex implementation (currently the vector\n * strategy, whose index() performs an embedding-provider round trip per record).\n * A whole reindex page arrives in one bulkIndex call, so an unbounded fan-out\n * would burst hundreds of concurrent provider requests from a single job.\n */\nconst BULK_INDEX_FALLBACK_CONCURRENCY = 4\n\n/**\n * Map items through an async worker with a fixed number of in-flight calls.\n * Rejects with the first error, matching Promise.all semantics.\n */\nasync function mapWithConcurrency<T>(\n items: T[],\n limit: number,\n worker: (item: T) => Promise<void>,\n): Promise<void> {\n if (items.length === 0) return\n\n let nextIndex = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n for (;;) {\n const currentIndex = nextIndex++\n if (currentIndex >= items.length) return\n await worker(items[currentIndex])\n }\n })\n\n await Promise.all(runners)\n}\n\nfunction normalizeOrganizationFilter(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n\n const values = Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n return values\n}\n\nfunction filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {\n const organizationIds = normalizeOrganizationFilter(options)\n if (!organizationIds) return results\n if (organizationIds.length === 0) return []\n\n const allowed = new Set(organizationIds)\n return results.filter((result) => {\n const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''\n return organizationId.length > 0 && allowed.has(organizationId)\n })\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n private readonly availabilityCache = new Map<SearchStrategyId, { value: boolean; expiresAt: number }>()\n private readonly availabilityInflight = new Map<SearchStrategyId, Promise<boolean>>()\n private readonly availabilityCacheTtlMs: number\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n this.availabilityCacheTtlMs = options.availabilityCacheTtlMs ?? STRATEGY_AVAILABILITY_CACHE_TTL_MS\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationFilter(options)\n if (organizationIds && organizationIds.length === 0) {\n return []\n }\n\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n const scoped = filterResultsByOrganizationScope(merged, options)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)\n }\n\n /**\n * Recompute configured presenters at request time and fill missing presenter\n * or navigation data for unconfigured results.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n this.throwOnStrategyFailures('index', strategies, results, {\n entityId: record.entityId,\n recordId: record.recordId,\n })\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n this.throwOnStrategyFailures('delete', strategies, results, { entityId, recordId })\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing, bounded so a strategy without a batch\n // implementation cannot turn one batch job into hundreds of concurrent writes.\n return mapWithConcurrency(records, BULK_INDEX_FALLBACK_CONCURRENCY, (record) =>\n this.executeStrategyIndex(strategy, record),\n )\n }),\n )\n\n // Collect failures and throw if any occurred\n const failures: Array<{ strategyId: string; error: string }> = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n const errorMessage = result.reason instanceof Error ? result.reason.message : result.reason\n failures.push({\n strategyId: strategy?.id || 'unknown',\n error: errorMessage,\n })\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: errorMessage,\n })\n }\n }\n\n if (failures.length > 0) {\n throw new Error(\n `Bulk indexing failed for ${failures.length} strategy(ies): ${failures\n .map((f) => `${f.strategyId} (${f.error})`)\n .join(', ')}`\n )\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string, organizationId?: string | null): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId, organizationId)\n }\n return Promise.resolve()\n }),\n )\n\n this.throwOnStrategyFailures('purge', strategies, results, { entityId })\n }\n\n /**\n * Inspect the settled results of a per-strategy write operation, log every\n * rejection, and re-throw an aggregated error when any strategy failed.\n *\n * Write operations (index/delete/purge) must surface failures to the caller\n * so the queue worker re-throws and the job is retried. Swallowing rejections\n * here causes silent, permanent index gaps on transient failures such as\n * Postgres connection-pool exhaustion (issue #3103). Successful strategies\n * still commit their work; only the aggregated failure propagates.\n */\n private throwOnStrategyFailures(\n operation: 'index' | 'delete' | 'purge',\n strategies: SearchStrategy[],\n results: PromiseSettledResult<unknown>[],\n context: { entityId: string; recordId?: string },\n ): void {\n const failures: Array<{ strategyId: string; reason: unknown }> = []\n\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n failures.push({ strategyId: strategy?.id || 'unknown', reason: result.reason })\n searchError('SearchService', `Strategy ${operation} failed`, {\n strategyId: strategy?.id,\n entityId: context.entityId,\n recordId: context.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n if (failures.length === 0) return\n\n const summary = `Search ${operation} failed for ${failures.length} strategy(ies): ${failures\n .map((failure) => {\n const message = failure.reason instanceof Error ? failure.reason.message : String(failure.reason)\n return `${failure.strategyId} (${message})`\n })\n .join(', ')}`\n\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n summary,\n )\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n this.availabilityCache.delete(strategy.id)\n this.availabilityInflight.delete(strategy.id)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n }\n\n /**\n * Invalidate the strategy availability cache.\n * Call after manual reconnects or env changes when callers must observe the\n * current backend state immediately rather than waiting for TTL expiry.\n */\n invalidateAvailabilityCache(strategyId?: SearchStrategyId): void {\n if (strategyId) {\n this.availabilityCache.delete(strategyId)\n this.availabilityInflight.delete(strategyId)\n return\n }\n this.availabilityCache.clear()\n this.availabilityInflight.clear()\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return this.checkStrategyAvailability(strategy)\n }\n\n /**\n * Resolve a strategy's availability via the short-lived TTL cache.\n * Coalesces concurrent callers onto a single in-flight probe to avoid\n * thundering-herd on remote backends.\n */\n private async checkStrategyAvailability(strategy: SearchStrategy): Promise<boolean> {\n const now = Date.now()\n const cached = this.availabilityCache.get(strategy.id)\n if (cached && cached.expiresAt > now) return cached.value\n\n const inflight = this.availabilityInflight.get(strategy.id)\n if (inflight) return inflight\n\n const probe = (async () => {\n try {\n const value = await strategy.isAvailable()\n this.availabilityCache.set(strategy.id, {\n value,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return value\n } catch {\n this.availabilityCache.set(strategy.id, {\n value: false,\n expiresAt: Date.now() + this.availabilityCacheTtlMs,\n })\n return false\n } finally {\n this.availabilityInflight.delete(strategy.id)\n }\n })()\n this.availabilityInflight.set(strategy.id, probe)\n return probe\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n * Probes run in parallel and reuse a short-lived per-strategy availability\n * cache, so hot paths pay the max latency of the slowest probe (or zero\n * when cached) instead of the sum of all probes.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const candidates: SearchStrategy[] = []\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) candidates.push(strategy)\n }\n\n const probes = await Promise.allSettled(\n candidates.map((strategy) => this.checkStrategyAvailability(strategy)),\n )\n\n const available: SearchStrategy[] = []\n for (let i = 0; i < probes.length; i++) {\n const probe = probes[i]\n if (probe.status === 'fulfilled' && probe.value) {\n available.push(candidates[i])\n }\n }\n\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|