@open-mercato/search 0.4.6-develop-6953d75a91 → 0.4.6-develop-90c3eb0e8a

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.
Files changed (35) hide show
  1. package/dist/modules/search/__integration__/TC-SEARCH-001.spec.js +64 -0
  2. package/dist/modules/search/__integration__/TC-SEARCH-001.spec.js.map +7 -0
  3. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +10 -0
  4. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +2 -2
  5. package/dist/modules/search/api/embeddings/reindex/route.js +23 -0
  6. package/dist/modules/search/api/embeddings/reindex/route.js.map +2 -2
  7. package/dist/modules/search/api/reindex/cancel/route.js +10 -0
  8. package/dist/modules/search/api/reindex/cancel/route.js.map +2 -2
  9. package/dist/modules/search/api/reindex/route.js +39 -0
  10. package/dist/modules/search/api/reindex/route.js.map +2 -2
  11. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +13 -26
  12. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +2 -2
  13. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +10 -6
  14. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +2 -2
  15. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +10 -6
  16. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
  17. package/dist/modules/search/lib/reindex-progress.js +151 -0
  18. package/dist/modules/search/lib/reindex-progress.js.map +7 -0
  19. package/dist/modules/search/workers/fulltext-index.worker.js +22 -2
  20. package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
  21. package/dist/modules/search/workers/vector-index.worker.js +25 -3
  22. package/dist/modules/search/workers/vector-index.worker.js.map +2 -2
  23. package/package.json +4 -4
  24. package/src/modules/search/README.md +13 -0
  25. package/src/modules/search/__integration__/TC-SEARCH-001.spec.ts +80 -0
  26. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +11 -0
  27. package/src/modules/search/api/embeddings/reindex/route.ts +27 -0
  28. package/src/modules/search/api/reindex/cancel/route.ts +11 -0
  29. package/src/modules/search/api/reindex/route.ts +44 -0
  30. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +13 -33
  31. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +12 -7
  32. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +12 -7
  33. package/src/modules/search/lib/reindex-progress.ts +202 -0
  34. package/src/modules/search/workers/fulltext-index.worker.ts +24 -2
  35. package/src/modules/search/workers/vector-index.worker.ts +27 -3
@@ -0,0 +1,64 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { login } from "@open-mercato/core/modules/core/__integration__/helpers/auth";
3
+ test.describe("TC-SEARCH-001: search reindex progress SSE refresh behavior", () => {
4
+ test("refreshes search settings from progress SSE events and reconnect without periodic polling", async ({ page }) => {
5
+ const requestCounts = {
6
+ settings: 0,
7
+ embeddings: 0
8
+ };
9
+ const onRequest = (rawRequest) => {
10
+ if (rawRequest.method() !== "GET") return;
11
+ const url = rawRequest.url();
12
+ if (url.includes("/api/search/settings")) {
13
+ requestCounts.settings += 1;
14
+ }
15
+ if (url.includes("/api/search/embeddings")) {
16
+ requestCounts.embeddings += 1;
17
+ }
18
+ };
19
+ const emitAppEvent = async (detail) => {
20
+ await page.evaluate((eventDetail) => {
21
+ window.dispatchEvent(new CustomEvent("om:event", { detail: eventDetail }));
22
+ }, detail);
23
+ };
24
+ try {
25
+ await login(page, "superadmin");
26
+ page.on("request", onRequest);
27
+ await page.goto("/backend/config/search");
28
+ await page.waitForLoadState("domcontentloaded");
29
+ await expect(page.getByRole("heading", { name: "Search Settings" })).toBeVisible();
30
+ await page.waitForTimeout(2e3);
31
+ const baseline = { ...requestCounts };
32
+ await emitAppEvent({
33
+ id: "progress.job.updated",
34
+ payload: {
35
+ jobId: "job-fulltext",
36
+ jobType: "search.reindex.fulltext",
37
+ status: "running",
38
+ progressPercent: 10,
39
+ processedCount: 10,
40
+ totalCount: 100
41
+ },
42
+ timestamp: Date.now(),
43
+ organizationId: "org"
44
+ });
45
+ await expect.poll(() => requestCounts.settings, { timeout: 15e3 }).toBeGreaterThan(baseline.settings);
46
+ await expect.poll(() => requestCounts.embeddings, { timeout: 15e3 }).toBeGreaterThan(baseline.embeddings);
47
+ const afterProgressEvent = { ...requestCounts };
48
+ await page.waitForTimeout(6e3);
49
+ expect(requestCounts.settings).toBe(afterProgressEvent.settings);
50
+ expect(requestCounts.embeddings).toBe(afterProgressEvent.embeddings);
51
+ await emitAppEvent({
52
+ id: "om:bridge:reconnected",
53
+ payload: {},
54
+ timestamp: Date.now(),
55
+ organizationId: "org"
56
+ });
57
+ await expect.poll(() => requestCounts.settings, { timeout: 15e3 }).toBeGreaterThan(afterProgressEvent.settings);
58
+ await expect.poll(() => requestCounts.embeddings, { timeout: 15e3 }).toBeGreaterThan(afterProgressEvent.embeddings);
59
+ } finally {
60
+ page.off("request", onRequest);
61
+ }
62
+ });
63
+ });
64
+ //# sourceMappingURL=TC-SEARCH-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'\n\ntype EventPayload = {\n id: string\n payload: Record<string, unknown>\n timestamp: number\n organizationId: string\n}\n\ntest.describe('TC-SEARCH-001: search reindex progress SSE refresh behavior', () => {\n test('refreshes search settings from progress SSE events and reconnect without periodic polling', async ({ page }) => {\n const requestCounts = {\n settings: 0,\n embeddings: 0,\n }\n\n const onRequest = (rawRequest: { url: () => string; method: () => string }) => {\n if (rawRequest.method() !== 'GET') return\n const url = rawRequest.url()\n if (url.includes('/api/search/settings')) {\n requestCounts.settings += 1\n }\n if (url.includes('/api/search/embeddings')) {\n requestCounts.embeddings += 1\n }\n }\n\n const emitAppEvent = async (detail: EventPayload) => {\n await page.evaluate((eventDetail) => {\n window.dispatchEvent(new CustomEvent('om:event', { detail: eventDetail }))\n }, detail)\n }\n\n try {\n await login(page, 'superadmin')\n page.on('request', onRequest)\n await page.goto('/backend/config/search')\n await page.waitForLoadState('domcontentloaded')\n await expect(page.getByRole('heading', { name: 'Search Settings' })).toBeVisible()\n\n await page.waitForTimeout(2_000)\n const baseline = { ...requestCounts }\n\n await emitAppEvent({\n id: 'progress.job.updated',\n payload: {\n jobId: 'job-fulltext',\n jobType: 'search.reindex.fulltext',\n status: 'running',\n progressPercent: 10,\n processedCount: 10,\n totalCount: 100,\n },\n timestamp: Date.now(),\n organizationId: 'org',\n })\n\n await expect.poll(() => requestCounts.settings, { timeout: 15_000 }).toBeGreaterThan(baseline.settings)\n await expect.poll(() => requestCounts.embeddings, { timeout: 15_000 }).toBeGreaterThan(baseline.embeddings)\n const afterProgressEvent = { ...requestCounts }\n\n await page.waitForTimeout(6_000)\n expect(requestCounts.settings).toBe(afterProgressEvent.settings)\n expect(requestCounts.embeddings).toBe(afterProgressEvent.embeddings)\n\n await emitAppEvent({\n id: 'om:bridge:reconnected',\n payload: {},\n timestamp: Date.now(),\n organizationId: 'org',\n })\n\n await expect.poll(() => requestCounts.settings, { timeout: 15_000 }).toBeGreaterThan(afterProgressEvent.settings)\n await expect.poll(() => requestCounts.embeddings, { timeout: 15_000 }).toBeGreaterThan(afterProgressEvent.embeddings)\n } finally {\n page.off('request', onRequest)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,aAAa;AAStB,KAAK,SAAS,+DAA+D,MAAM;AACjF,OAAK,6FAA6F,OAAO,EAAE,KAAK,MAAM;AACpH,UAAM,gBAAgB;AAAA,MACpB,UAAU;AAAA,MACV,YAAY;AAAA,IACd;AAEA,UAAM,YAAY,CAAC,eAA4D;AAC7E,UAAI,WAAW,OAAO,MAAM,MAAO;AACnC,YAAM,MAAM,WAAW,IAAI;AAC3B,UAAI,IAAI,SAAS,sBAAsB,GAAG;AACxC,sBAAc,YAAY;AAAA,MAC5B;AACA,UAAI,IAAI,SAAS,wBAAwB,GAAG;AAC1C,sBAAc,cAAc;AAAA,MAC9B;AAAA,IACF;AAEA,UAAM,eAAe,OAAO,WAAyB;AACnD,YAAM,KAAK,SAAS,CAAC,gBAAgB;AACnC,eAAO,cAAc,IAAI,YAAY,YAAY,EAAE,QAAQ,YAAY,CAAC,CAAC;AAAA,MAC3E,GAAG,MAAM;AAAA,IACX;AAEA,QAAI;AACF,YAAM,MAAM,MAAM,YAAY;AAC9B,WAAK,GAAG,WAAW,SAAS;AAC5B,YAAM,KAAK,KAAK,wBAAwB;AACxC,YAAM,KAAK,iBAAiB,kBAAkB;AAC9C,YAAM,OAAO,KAAK,UAAU,WAAW,EAAE,MAAM,kBAAkB,CAAC,CAAC,EAAE,YAAY;AAEjF,YAAM,KAAK,eAAe,GAAK;AAC/B,YAAM,WAAW,EAAE,GAAG,cAAc;AAEpC,YAAM,aAAa;AAAA,QACjB,IAAI;AAAA,QACJ,SAAS;AAAA,UACP,OAAO;AAAA,UACP,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,iBAAiB;AAAA,UACjB,gBAAgB;AAAA,UAChB,YAAY;AAAA,QACd;AAAA,QACA,WAAW,KAAK,IAAI;AAAA,QACpB,gBAAgB;AAAA,MAClB,CAAC;AAED,YAAM,OAAO,KAAK,MAAM,cAAc,UAAU,EAAE,SAAS,KAAO,CAAC,EAAE,gBAAgB,SAAS,QAAQ;AACtG,YAAM,OAAO,KAAK,MAAM,cAAc,YAAY,EAAE,SAAS,KAAO,CAAC,EAAE,gBAAgB,SAAS,UAAU;AAC1G,YAAM,qBAAqB,EAAE,GAAG,cAAc;AAE9C,YAAM,KAAK,eAAe,GAAK;AAC/B,aAAO,cAAc,QAAQ,EAAE,KAAK,mBAAmB,QAAQ;AAC/D,aAAO,cAAc,UAAU,EAAE,KAAK,mBAAmB,UAAU;AAEnE,YAAM,aAAa;AAAA,QACjB,IAAI;AAAA,QACJ,SAAS,CAAC;AAAA,QACV,WAAW,KAAK,IAAI;AAAA,QACpB,gBAAgB;AAAA,MAClB,CAAC;AAED,YAAM,OAAO,KAAK,MAAM,cAAc,UAAU,EAAE,SAAS,KAAO,CAAC,EAAE,gBAAgB,mBAAmB,QAAQ;AAChH,YAAM,OAAO,KAAK,MAAM,cAAc,YAAY,EAAE,SAAS,KAAO,CAAC,EAAE,gBAAgB,mBAAmB,UAAU;AAAA,IACtH,UAAE;AACA,WAAK,IAAI,WAAW,SAAS;AAAA,IAC/B;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { clearReindexLock } from "../../../../lib/reindex-lock.js";
5
+ import { cancelReindexProgress } from "../../../../lib/reindex-progress.js";
5
6
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
7
  import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
7
8
  import { embeddingsReindexCancelOpenApi } from "../../../openapi.js";
@@ -16,6 +17,7 @@ async function POST(req) {
16
17
  }
17
18
  const container = await createRequestContainer();
18
19
  const em = container.resolve("em");
20
+ const progressService = container.resolve("progressService");
19
21
  const knex = em.getConnection().getKnex();
20
22
  let queue;
21
23
  try {
@@ -32,6 +34,14 @@ async function POST(req) {
32
34
  }
33
35
  }
34
36
  await clearReindexLock(knex, auth.tenantId, "vector", auth.orgId ?? null);
37
+ await cancelReindexProgress({
38
+ em,
39
+ progressService,
40
+ type: "vector",
41
+ tenantId: auth.tenantId,
42
+ organizationId: auth.orgId ?? null,
43
+ userId: auth.sub ?? null
44
+ });
35
45
  try {
36
46
  const em2 = container.resolve("em");
37
47
  await recordIndexerLog(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/search/api/embeddings/reindex/cancel/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { Queue } from '@open-mercato/queue'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { clearReindexLock } from '../../../../lib/reindex-lock'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { embeddingsReindexCancelOpenApi } from '../../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n let queue: Queue | undefined\n try {\n queue = container.resolve<Queue>('vectorIndexQueue')\n } catch {\n // Queue not available - just clear the lock\n }\n\n let jobsRemoved = 0\n if (queue) {\n try {\n const countsBefore = await queue.getJobCounts()\n jobsRemoved = countsBefore.waiting + countsBefore.active\n await queue.clear()\n } catch {\n // Queue clear failed - continue to clear lock\n }\n }\n\n await clearReindexLock(knex, auth.tenantId, 'vector', auth.orgId ?? null)\n\n // Log the cancellation\n try {\n const em = container.resolve('em')\n await recordIndexerLog(\n { em },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex.cancel',\n message: `Cancelled vector reindex operation (${jobsRemoved} jobs removed)`,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n details: { jobsRemoved },\n },\n )\n } catch {\n // Logging failure should not fail the cancel operation\n }\n\n try {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n\n return NextResponse.json({\n ok: true,\n jobsRemoved,\n })\n}\n\nexport const openApi = embeddingsReindexCancelOpenApi\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAInC,SAAS,wBAAwB;AACjC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,sCAAsC;AAExC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,UAAU;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,2BAA2B,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,OAAQ,GAAG,cAAc,EAAyC,QAAQ;AAEhF,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,kBAAkB;AAAA,EACrD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI;AACF,YAAM,eAAe,MAAM,MAAM,aAAa;AAC9C,oBAAc,aAAa,UAAU,aAAa;AAClD,YAAM,MAAM,MAAM;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,iBAAiB,MAAM,KAAK,UAAU,UAAU,KAAK,SAAS,IAAI;AAGxE,MAAI;AACF,UAAMA,MAAK,UAAU,QAAQ,IAAI;AACjC,UAAM;AAAA,MACJ,EAAE,IAAAA,IAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,uCAAuC,WAAW;AAAA,QAC3D,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,SAAS;AAAA,QAC9B,SAAS,EAAE,YAAY;AAAA,MACzB;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { Queue } from '@open-mercato/queue'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { clearReindexLock } from '../../../../lib/reindex-lock'\nimport { cancelReindexProgress } from '../../../../lib/reindex-progress'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { embeddingsReindexCancelOpenApi } from '../../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const progressService = container.resolve('progressService') as ProgressService\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n let queue: Queue | undefined\n try {\n queue = container.resolve<Queue>('vectorIndexQueue')\n } catch {\n // Queue not available - just clear the lock\n }\n\n let jobsRemoved = 0\n if (queue) {\n try {\n const countsBefore = await queue.getJobCounts()\n jobsRemoved = countsBefore.waiting + countsBefore.active\n await queue.clear()\n } catch {\n // Queue clear failed - continue to clear lock\n }\n }\n\n await clearReindexLock(knex, auth.tenantId, 'vector', auth.orgId ?? null)\n await cancelReindexProgress({\n em,\n progressService,\n type: 'vector',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub ?? null,\n })\n\n // Log the cancellation\n try {\n const em = container.resolve('em')\n await recordIndexerLog(\n { em },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex.cancel',\n message: `Cancelled vector reindex operation (${jobsRemoved} jobs removed)`,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n details: { jobsRemoved },\n },\n )\n } catch {\n // Logging failure should not fail the cancel operation\n }\n\n try {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n\n return NextResponse.json({\n ok: true,\n jobsRemoved,\n })\n}\n\nexport const openApi = embeddingsReindexCancelOpenApi\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAKnC,SAAS,wBAAwB;AACjC,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,sCAAsC;AAExC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,UAAU;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,2BAA2B,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,kBAAkB,UAAU,QAAQ,iBAAiB;AAC3D,QAAM,OAAQ,GAAG,cAAc,EAAyC,QAAQ;AAEhF,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,kBAAkB;AAAA,EACrD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI;AACF,YAAM,eAAe,MAAM,MAAM,aAAa;AAC9C,oBAAc,aAAa,UAAU,aAAa;AAClD,YAAM,MAAM,MAAM;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,iBAAiB,MAAM,KAAK,UAAU,UAAU,KAAK,SAAS,IAAI;AACxE,QAAM,sBAAsB;AAAA,IAC1B;AAAA,IACA;AAAA,IACA,MAAM;AAAA,IACN,UAAU,KAAK;AAAA,IACf,gBAAgB,KAAK,SAAS;AAAA,IAC9B,QAAQ,KAAK,OAAO;AAAA,EACtB,CAAC;AAGD,MAAI;AACF,UAAMA,MAAK,UAAU,QAAQ,IAAI;AACjC,UAAM;AAAA,MACJ,EAAE,IAAAA,IAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,uCAAuC,WAAW;AAAA,QAC3D,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,SAAS;AAAA,QAC9B,SAAS,EAAE,YAAY;AAAA,MACzB;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
6
6
  "names": ["em"]
7
7
  }
@@ -6,6 +6,10 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
6
  import { resolveEmbeddingConfig } from "../../../lib/embedding-config.js";
7
7
  import { searchDebug, searchDebugWarn, searchError } from "../../../../../lib/debug.js";
8
8
  import { acquireReindexLock, getReindexLockStatus } from "../../../lib/reindex-lock.js";
9
+ import {
10
+ ensureReindexProgressJob,
11
+ failReindexProgress
12
+ } from "../../../lib/reindex-progress.js";
9
13
  import { embeddingsReindexOpenApi } from "../../openapi.js";
10
14
  const metadata = {
11
15
  POST: { requireAuth: true, requireFeatures: ["search.embeddings.manage"] }
@@ -25,6 +29,7 @@ async function POST(req) {
25
29
  const purgeFirst = payload?.purgeFirst === true;
26
30
  const container = await createRequestContainer();
27
31
  const em = container.resolve("em");
32
+ const progressService = container.resolve("progressService");
28
33
  const knex = em.getConnection().getKnex();
29
34
  const existingLock = await getReindexLockStatus(knex, auth.tenantId, { type: "vector" });
30
35
  if (existingLock) {
@@ -116,6 +121,16 @@ async function POST(req) {
116
121
  useQueue: true
117
122
  });
118
123
  }
124
+ await ensureReindexProgressJob({
125
+ em: em2,
126
+ progressService,
127
+ type: "vector",
128
+ tenantId: auth.tenantId,
129
+ organizationId: auth.orgId ?? null,
130
+ userId: auth.sub ?? null,
131
+ totalCount: result.recordsIndexed,
132
+ description: entityId ? `Vector reindex ${entityId} (queued)` : "Vector reindex all entities (queued)"
133
+ });
119
134
  await recordIndexerLog(
120
135
  { em: em2 ?? void 0 },
121
136
  {
@@ -148,6 +163,14 @@ async function POST(req) {
148
163
  stack: error instanceof Error ? error.stack : void 0,
149
164
  status
150
165
  });
166
+ await failReindexProgress({
167
+ em,
168
+ progressService,
169
+ type: "vector",
170
+ tenantId: auth.tenantId,
171
+ organizationId: auth.orgId ?? null,
172
+ errorMessage: error instanceof Error ? error.message : "Vector reindex failed"
173
+ });
151
174
  return NextResponse.json(
152
175
  { error: t("search.api.errors.reindexFailed", "Vector reindex failed. Please try again or contact support.") },
153
176
  { status: status >= 400 ? status : 500 }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/search/api/embeddings/reindex/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { SearchIndexer } from '../../../../../indexer/search-indexer'\nimport type { EmbeddingService } from '../../../../../vector'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../../../lib/debug'\nimport { acquireReindexLock, clearReindexLock, getReindexLockStatus } from '../../../lib/reindex-lock'\nimport { embeddingsReindexOpenApi } from '../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n let payload: { entityId?: string; purgeFirst?: boolean } = {}\n try {\n payload = await req.json()\n } catch {\n // Default values\n }\n\n const entityId = typeof payload?.entityId === 'string' ? payload.entityId : undefined\n const purgeFirst = payload?.purgeFirst === true\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n // Check if another vector reindex operation is already in progress\n const existingLock = await getReindexLockStatus(knex, auth.tenantId, { type: 'vector' })\n if (existingLock) {\n const startedAt = new Date(existingLock.startedAt)\n return NextResponse.json(\n {\n error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),\n lock: {\n type: existingLock.type,\n action: existingLock.action,\n startedAt: existingLock.startedAt,\n elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),\n processedCount: existingLock.processedCount,\n totalCount: existingLock.totalCount,\n },\n },\n { status: 409 }\n )\n }\n\n // Acquire lock before starting the operation\n const { acquired: lockAcquired } = await acquireReindexLock(knex, {\n type: 'vector',\n action: entityId ? `reindex:${entityId}` : 'reindex:all',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n\n if (!lockAcquired) {\n return NextResponse.json(\n { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },\n { status: 409 }\n )\n }\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any = null\n try {\n em = container.resolve('em')\n } catch {\n // em not available\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = container.resolve('searchIndexer') as SearchIndexer\n } catch {\n return NextResponse.json(\n { error: t('search.api.errors.indexUnavailable', 'Search indexer unavailable') },\n { status: 503 }\n )\n }\n\n // Load saved embedding config and update the embedding service\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n searchDebug('search.embeddings.reindex', 'using embedding config', {\n providerId: embeddingConfig.providerId,\n model: embeddingConfig.model,\n dimension: embeddingConfig.dimension,\n })\n }\n } catch (err) {\n searchDebugWarn('search.embeddings.reindex', 'failed to load embedding config, using defaults', {\n error: err instanceof Error ? err.message : err,\n })\n }\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex',\n message: entityId\n ? `Vector reindex requested for ${entityId}`\n : 'Vector reindex requested for all entities',\n entityType: entityId ?? null,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n details: { purgeFirst },\n },\n ).catch(() => undefined)\n\n // Use queue-based vector reindexing (similar to fulltext)\n // This enqueues batches for background processing by workers\n let result\n if (entityId) {\n result = await searchIndexer.reindexEntityToVector({\n entityId: entityId as EntityId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n purgeFirst,\n useQueue: true,\n })\n } else {\n result = await searchIndexer.reindexAllToVector({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n purgeFirst,\n useQueue: true,\n })\n }\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex',\n message: result.jobsEnqueued\n ? `Vector reindex enqueued ${result.jobsEnqueued} jobs for ${entityId ?? 'all entities'}`\n : `Vector reindex completed for ${entityId ?? 'all entities'}`,\n entityType: entityId ?? null,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n details: {\n purgeFirst,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n success: result.success,\n },\n },\n ).catch(() => undefined)\n\n return NextResponse.json({\n ok: result.success,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n entitiesProcessed: result.entitiesProcessed,\n errors: result.errors.length > 0 ? result.errors : undefined,\n })\n } catch (error: unknown) {\n const err = error as { message?: string; status?: number; statusCode?: number }\n const status = typeof err?.status === 'number'\n ? err.status\n : (typeof err?.statusCode === 'number' ? err.statusCode : 500)\n searchError('search.embeddings.reindex', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n status,\n })\n return NextResponse.json(\n { error: t('search.api.errors.reindexFailed', 'Vector reindex failed. Please try again or contact support.') },\n { status: status >= 400 ? status : 500 }\n )\n } finally {\n // Do NOT clear lock here - vector reindex always uses queue mode\n // Workers update heartbeat and stale detection handles cleanup when done\n\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = embeddingsReindexOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { SearchIndexer } from '../../../../../indexer/search-indexer'\nimport type { EmbeddingService } from '../../../../../vector'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../../../lib/debug'\nimport { acquireReindexLock, clearReindexLock, getReindexLockStatus } from '../../../lib/reindex-lock'\nimport {\n ensureReindexProgressJob,\n failReindexProgress,\n} from '../../../lib/reindex-progress'\nimport { embeddingsReindexOpenApi } from '../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n let payload: { entityId?: string; purgeFirst?: boolean } = {}\n try {\n payload = await req.json()\n } catch {\n // Default values\n }\n\n const entityId = typeof payload?.entityId === 'string' ? payload.entityId : undefined\n const purgeFirst = payload?.purgeFirst === true\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const progressService = container.resolve('progressService') as ProgressService\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n // Check if another vector reindex operation is already in progress\n const existingLock = await getReindexLockStatus(knex, auth.tenantId, { type: 'vector' })\n if (existingLock) {\n const startedAt = new Date(existingLock.startedAt)\n return NextResponse.json(\n {\n error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),\n lock: {\n type: existingLock.type,\n action: existingLock.action,\n startedAt: existingLock.startedAt,\n elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),\n processedCount: existingLock.processedCount,\n totalCount: existingLock.totalCount,\n },\n },\n { status: 409 }\n )\n }\n\n // Acquire lock before starting the operation\n const { acquired: lockAcquired } = await acquireReindexLock(knex, {\n type: 'vector',\n action: entityId ? `reindex:${entityId}` : 'reindex:all',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n\n if (!lockAcquired) {\n return NextResponse.json(\n { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },\n { status: 409 }\n )\n }\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any = null\n try {\n em = container.resolve('em')\n } catch {\n // em not available\n }\n\n let searchIndexer: SearchIndexer\n try {\n searchIndexer = container.resolve('searchIndexer') as SearchIndexer\n } catch {\n return NextResponse.json(\n { error: t('search.api.errors.indexUnavailable', 'Search indexer unavailable') },\n { status: 503 }\n )\n }\n\n // Load saved embedding config and update the embedding service\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n searchDebug('search.embeddings.reindex', 'using embedding config', {\n providerId: embeddingConfig.providerId,\n model: embeddingConfig.model,\n dimension: embeddingConfig.dimension,\n })\n }\n } catch (err) {\n searchDebugWarn('search.embeddings.reindex', 'failed to load embedding config, using defaults', {\n error: err instanceof Error ? err.message : err,\n })\n }\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex',\n message: entityId\n ? `Vector reindex requested for ${entityId}`\n : 'Vector reindex requested for all entities',\n entityType: entityId ?? null,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n details: { purgeFirst },\n },\n ).catch(() => undefined)\n\n // Use queue-based vector reindexing (similar to fulltext)\n // This enqueues batches for background processing by workers\n let result\n if (entityId) {\n result = await searchIndexer.reindexEntityToVector({\n entityId: entityId as EntityId,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n purgeFirst,\n useQueue: true,\n })\n } else {\n result = await searchIndexer.reindexAllToVector({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n purgeFirst,\n useQueue: true,\n })\n }\n\n await ensureReindexProgressJob({\n em,\n progressService,\n type: 'vector',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub ?? null,\n totalCount: result.recordsIndexed,\n description: entityId\n ? `Vector reindex ${entityId} (queued)`\n : 'Vector reindex all entities (queued)',\n })\n\n await recordIndexerLog(\n { em: em ?? undefined },\n {\n source: 'vector',\n handler: 'api:search.embeddings.reindex',\n message: result.jobsEnqueued\n ? `Vector reindex enqueued ${result.jobsEnqueued} jobs for ${entityId ?? 'all entities'}`\n : `Vector reindex completed for ${entityId ?? 'all entities'}`,\n entityType: entityId ?? null,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n details: {\n purgeFirst,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n success: result.success,\n },\n },\n ).catch(() => undefined)\n\n return NextResponse.json({\n ok: result.success,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n entitiesProcessed: result.entitiesProcessed,\n errors: result.errors.length > 0 ? result.errors : undefined,\n })\n } catch (error: unknown) {\n const err = error as { message?: string; status?: number; statusCode?: number }\n const status = typeof err?.status === 'number'\n ? err.status\n : (typeof err?.statusCode === 'number' ? err.statusCode : 500)\n searchError('search.embeddings.reindex', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n status,\n })\n await failReindexProgress({\n em,\n progressService,\n type: 'vector',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n errorMessage: error instanceof Error ? error.message : 'Vector reindex failed',\n })\n return NextResponse.json(\n { error: t('search.api.errors.reindexFailed', 'Vector reindex failed. Please try again or contact support.') },\n { status: status >= 400 ? status : 500 }\n )\n } finally {\n // Do NOT clear lock here - vector reindex always uses queue mode\n // Workers update heartbeat and stale detection handles cleanup when done\n\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = embeddingsReindexOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": ["em"]
7
7
  }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { clearReindexLock } from "../../../lib/reindex-lock.js";
5
+ import { cancelReindexProgress } from "../../../lib/reindex-progress.js";
5
6
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
7
  import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
7
8
  import { reindexCancelOpenApi } from "../../openapi.js";
@@ -16,6 +17,7 @@ async function POST(req) {
16
17
  }
17
18
  const container = await createRequestContainer();
18
19
  const em = container.resolve("em");
20
+ const progressService = container.resolve("progressService");
19
21
  const knex = em.getConnection().getKnex();
20
22
  let queue;
21
23
  try {
@@ -32,6 +34,14 @@ async function POST(req) {
32
34
  }
33
35
  }
34
36
  await clearReindexLock(knex, auth.tenantId, "fulltext", auth.orgId ?? null);
37
+ await cancelReindexProgress({
38
+ em,
39
+ progressService,
40
+ type: "fulltext",
41
+ tenantId: auth.tenantId,
42
+ organizationId: auth.orgId ?? null,
43
+ userId: auth.sub ?? null
44
+ });
35
45
  try {
36
46
  const em2 = container.resolve("em");
37
47
  await recordIndexerLog(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/search/api/reindex/cancel/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { Queue } from '@open-mercato/queue'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { clearReindexLock } from '../../../lib/reindex-lock'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { reindexCancelOpenApi } from '../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.reindex'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n let queue: Queue | undefined\n try {\n queue = container.resolve<Queue>('fulltextIndexQueue')\n } catch {\n // Queue not available - just clear the lock\n }\n\n let jobsRemoved = 0\n if (queue) {\n try {\n const countsBefore = await queue.getJobCounts()\n jobsRemoved = countsBefore.waiting + countsBefore.active\n await queue.clear()\n } catch {\n // Queue clear failed - continue to clear lock\n }\n }\n\n await clearReindexLock(knex, auth.tenantId, 'fulltext', auth.orgId ?? null)\n\n // Log the cancellation\n try {\n const em = container.resolve('em')\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex.cancel',\n message: `Cancelled fulltext reindex operation (${jobsRemoved} jobs removed)`,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n details: { jobsRemoved },\n },\n )\n } catch {\n // Logging failure should not fail the cancel operation\n }\n\n try {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n\n return NextResponse.json({\n ok: true,\n jobsRemoved,\n })\n}\n\nexport const openApi = reindexCancelOpenApi\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAInC,SAAS,wBAAwB;AACjC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,4BAA4B;AAE9B,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,UAAU;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,2BAA2B,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,OAAQ,GAAG,cAAc,EAAyC,QAAQ;AAEhF,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,oBAAoB;AAAA,EACvD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI;AACF,YAAM,eAAe,MAAM,MAAM,aAAa;AAC9C,oBAAc,aAAa,UAAU,aAAa;AAClD,YAAM,MAAM,MAAM;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,iBAAiB,MAAM,KAAK,UAAU,YAAY,KAAK,SAAS,IAAI;AAG1E,MAAI;AACF,UAAMA,MAAK,UAAU,QAAQ,IAAI;AACjC,UAAM;AAAA,MACJ,EAAE,IAAAA,IAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,yCAAyC,WAAW;AAAA,QAC7D,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,SAAS;AAAA,QAC9B,SAAS,EAAE,YAAY;AAAA,MACzB;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { Queue } from '@open-mercato/queue'\nimport type { Knex } from 'knex'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { clearReindexLock } from '../../../lib/reindex-lock'\nimport { cancelReindexProgress } from '../../../lib/reindex-progress'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { reindexCancelOpenApi } from '../../openapi'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.reindex'] },\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const progressService = container.resolve('progressService') as ProgressService\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n let queue: Queue | undefined\n try {\n queue = container.resolve<Queue>('fulltextIndexQueue')\n } catch {\n // Queue not available - just clear the lock\n }\n\n let jobsRemoved = 0\n if (queue) {\n try {\n const countsBefore = await queue.getJobCounts()\n jobsRemoved = countsBefore.waiting + countsBefore.active\n await queue.clear()\n } catch {\n // Queue clear failed - continue to clear lock\n }\n }\n\n await clearReindexLock(knex, auth.tenantId, 'fulltext', auth.orgId ?? null)\n await cancelReindexProgress({\n em,\n progressService,\n type: 'fulltext',\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub ?? null,\n })\n\n // Log the cancellation\n try {\n const em = container.resolve('em')\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex.cancel',\n message: `Cancelled fulltext reindex operation (${jobsRemoved} jobs removed)`,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n details: { jobsRemoved },\n },\n )\n } catch {\n // Logging failure should not fail the cancel operation\n }\n\n try {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n\n return NextResponse.json({\n ok: true,\n jobsRemoved,\n })\n}\n\nexport const openApi = reindexCancelOpenApi\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAKnC,SAAS,wBAAwB;AACjC,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,4BAA4B;AAE9B,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,UAAU;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,2BAA2B,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,kBAAkB,UAAU,QAAQ,iBAAiB;AAC3D,QAAM,OAAQ,GAAG,cAAc,EAAyC,QAAQ;AAEhF,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,oBAAoB;AAAA,EACvD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI;AACF,YAAM,eAAe,MAAM,MAAM,aAAa;AAC9C,oBAAc,aAAa,UAAU,aAAa;AAClD,YAAM,MAAM,MAAM;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,iBAAiB,MAAM,KAAK,UAAU,YAAY,KAAK,SAAS,IAAI;AAC1E,QAAM,sBAAsB;AAAA,IAC1B;AAAA,IACA;AAAA,IACA,MAAM;AAAA,IACN,UAAU,KAAK;AAAA,IACf,gBAAgB,KAAK,SAAS;AAAA,IAC9B,QAAQ,KAAK,OAAO;AAAA,EACtB,CAAC;AAGD,MAAI;AACF,UAAMA,MAAK,UAAU,QAAQ,IAAI;AACjC,UAAM;AAAA,MACJ,EAAE,IAAAA,IAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,yCAAyC,WAAW;AAAA,QAC7D,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,SAAS;AAAA,QAC9B,SAAS,EAAE,YAAY;AAAA,MACzB;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
6
6
  "names": ["em"]
7
7
  }
@@ -10,6 +10,11 @@ import {
10
10
  clearReindexLock,
11
11
  getReindexLockStatus
12
12
  } from "../../lib/reindex-lock.js";
13
+ import {
14
+ completeReindexProgress,
15
+ ensureReindexProgressJob,
16
+ failReindexProgress
17
+ } from "../../lib/reindex-progress.js";
13
18
  import { reindexOpenApi } from "../openapi.js";
14
19
  async function collectStrategyStats(strategies, tenantId) {
15
20
  const stats = {};
@@ -51,6 +56,7 @@ async function POST(req) {
51
56
  const useQueue = payload.useQueue !== false;
52
57
  const container = await createRequestContainer();
53
58
  const em = container.resolve("em");
59
+ const progressService = container.resolve("progressService");
54
60
  const knex = em.getConnection().getKnex();
55
61
  const existingLock = await getReindexLockStatus(knex, tenantId, { type: "fulltext" });
56
62
  if (existingLock) {
@@ -228,6 +234,31 @@ async function POST(req) {
228
234
  );
229
235
  }
230
236
  }
237
+ await ensureReindexProgressJob({
238
+ em,
239
+ progressService,
240
+ type: "fulltext",
241
+ tenantId,
242
+ organizationId: auth.orgId ?? null,
243
+ userId: auth.sub ?? null,
244
+ totalCount: result.recordsIndexed,
245
+ description: entityId ? `Reindex ${entityId} (${useQueue ? "queued" : "sync"})` : `Reindex all entities (${useQueue ? "queued" : "sync"})`
246
+ });
247
+ if (!useQueue) {
248
+ await completeReindexProgress({
249
+ em,
250
+ progressService,
251
+ type: "fulltext",
252
+ tenantId,
253
+ organizationId: auth.orgId ?? null,
254
+ resultSummary: {
255
+ entitiesProcessed: result.entitiesProcessed,
256
+ recordsIndexed: result.recordsIndexed,
257
+ jobsEnqueued: result.jobsEnqueued ?? 0,
258
+ errors: result.errors.length
259
+ }
260
+ });
261
+ }
231
262
  const stats2 = await collectStrategyStats(searchStrategies, tenantId);
232
263
  return toJson({
233
264
  ok: result.success,
@@ -312,6 +343,14 @@ async function POST(req) {
312
343
  payload: { action, entityId, useQueue }
313
344
  }
314
345
  );
346
+ await failReindexProgress({
347
+ em,
348
+ progressService,
349
+ type: "fulltext",
350
+ tenantId,
351
+ organizationId: auth.orgId ?? null,
352
+ errorMessage: error instanceof Error ? error.message : "Fulltext reindex failed"
353
+ });
315
354
  return toJson(
316
355
  { error: t("search.api.errors.reindexFailed", "Reindex operation failed. Please try again or contact support.") },
317
356
  { status: 500 }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/search/api/reindex/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { SearchStrategy } from '@open-mercato/shared/modules/search'\nimport type { SearchIndexer } from '@open-mercato/search/indexer'\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 { EntityManager } from '@mikro-orm/postgresql'\nimport type { Knex } from 'knex'\nimport { searchDebug, searchError } from '../../../../lib/debug'\nimport {\n acquireReindexLock,\n clearReindexLock,\n getReindexLockStatus,\n} from '../../lib/reindex-lock'\nimport { reindexOpenApi } from '../openapi'\n\n/** Strategy with optional stats support */\ntype StrategyWithStats = SearchStrategy & {\n getIndexStats?: (tenantId: string) => Promise<Record<string, unknown> | null>\n clearIndex?: (tenantId: string) => Promise<void>\n recreateIndex?: (tenantId: string) => Promise<void>\n}\n\n/** Collect stats from all strategies that support it */\nasync function collectStrategyStats(\n strategies: StrategyWithStats[],\n tenantId: string\n): Promise<Record<string, Record<string, unknown> | null>> {\n const stats: Record<string, Record<string, unknown> | null> = {}\n for (const strategy of strategies) {\n if (typeof strategy.getIndexStats === 'function') {\n try {\n const isAvailable = await strategy.isAvailable()\n if (isAvailable) {\n stats[strategy.id] = await strategy.getIndexStats(tenantId)\n }\n } catch {\n // Skip strategy if stats collection fails\n }\n }\n }\n return stats\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.reindex'] },\n}\n\ntype ReindexAction = 'clear' | 'recreate' | 'reindex'\n\nconst toJson = (payload: Record<string, unknown>, init?: ResponseInit) => NextResponse.json(payload, init)\n\nconst unauthorized = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return await unauthorized()\n }\n\n // Capture tenantId as non-null for TypeScript (we checked above)\n const tenantId = auth.tenantId\n\n let payload: { action?: ReindexAction; entityId?: string; useQueue?: boolean } = {}\n try {\n payload = await req.json()\n } catch {\n // Default to reindex\n }\n\n const action: ReindexAction =\n payload.action === 'clear' ? 'clear' :\n payload.action === 'recreate' ? 'recreate' : 'reindex'\n const entityId = typeof payload.entityId === 'string' ? payload.entityId : undefined\n // Use queue by default (requires queue workers to be running), can be disabled with useQueue: false\n const useQueue = payload.useQueue !== false\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n // Check if another fulltext reindex operation is already in progress\n const existingLock = await getReindexLockStatus(knex, tenantId, { type: 'fulltext' })\n if (existingLock) {\n const startedAt = new Date(existingLock.startedAt)\n return NextResponse.json(\n {\n error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),\n lock: {\n type: existingLock.type,\n action: existingLock.action,\n startedAt: existingLock.startedAt,\n elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),\n processedCount: existingLock.processedCount,\n totalCount: existingLock.totalCount,\n },\n },\n { status: 409 }\n )\n }\n\n // Acquire lock before starting the operation\n const { acquired: lockAcquired } = await acquireReindexLock(knex, {\n type: 'fulltext',\n action,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n })\n\n if (!lockAcquired) {\n return NextResponse.json(\n { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },\n { status: 409 }\n )\n }\n\n try {\n // Get all search strategies\n const searchStrategies = (container.resolve('searchStrategies') as StrategyWithStats[] | undefined) ?? []\n\n // Find a strategy that supports index management (clear/recreate)\n const indexableStrategy = searchStrategies.find(\n (s) => typeof s.clearIndex === 'function' || typeof s.recreateIndex === 'function'\n )\n\n if (!indexableStrategy) {\n return toJson(\n { error: t('search.api.errors.noIndexableStrategy', 'No indexable search strategy is configured') },\n { status: 503 }\n )\n }\n\n // Check if strategy is available\n const isAvailable = await indexableStrategy.isAvailable()\n if (!isAvailable) {\n return toJson(\n { error: t('search.api.errors.strategyUnavailable', 'Search strategy is not available') },\n { status: 503 }\n )\n }\n\n // Perform the requested action\n if (action === 'reindex') {\n // Full reindex: recreate index and re-index all data\n const searchIndexer = container.resolve('searchIndexer') as SearchIndexer | undefined\n if (!searchIndexer) {\n return toJson(\n { error: t('search.api.errors.indexerUnavailable', 'Search indexer is not available') },\n { status: 503 }\n )\n }\n\n let result\n const orgId = typeof auth.orgId === 'string' ? auth.orgId : null\n\n // Debug: List enabled entities\n const enabledEntities = searchIndexer.listEnabledEntities()\n searchDebug('search.reindex', 'Starting reindex', {\n tenantId: tenantId,\n orgId,\n enabledEntities,\n entityId: entityId ?? 'all',\n useQueue,\n })\n\n // Log reindex started\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: entityId\n ? `Starting Meilisearch reindex for ${entityId}`\n : `Starting Meilisearch reindex for all entities (${enabledEntities.join(', ')})`,\n entityType: entityId ?? null,\n tenantId: tenantId,\n organizationId: orgId,\n details: { enabledEntities, useQueue },\n },\n )\n\n if (entityId) {\n // Reindex specific entity\n result = await searchIndexer.reindexEntityToFulltext({\n entityId: entityId as EntityId,\n tenantId: tenantId,\n organizationId: orgId,\n recreateIndex: true,\n useQueue,\n onProgress: (progress) => {\n searchDebug('search.reindex', 'Progress', progress)\n // Note: Heartbeat is updated by workers during job processing, not during enqueueing\n },\n })\n searchDebug('search.reindex', 'Reindexed entity to Meilisearch', {\n entityId,\n tenantId: tenantId,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n errors: result.errors,\n })\n\n // Log to indexer status logs\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: useQueue\n ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of ${entityId}`\n : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${entityId}`,\n entityType: entityId,\n tenantId: tenantId,\n organizationId: orgId,\n details: {\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n useQueue,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n },\n )\n\n // Log any batch errors to error logs\n for (const err of result.errors) {\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error: new Error(err.error),\n entityType: err.entityId,\n tenantId: tenantId,\n organizationId: orgId,\n payload: { action, useQueue },\n },\n )\n }\n } else {\n // Reindex all entities\n result = await searchIndexer.reindexAllToFulltext({\n tenantId: tenantId,\n organizationId: orgId,\n recreateIndex: true,\n useQueue,\n onProgress: (progress) => {\n searchDebug('search.reindex', 'Progress', progress)\n // Note: Heartbeat is updated by workers during job processing, not during enqueueing\n },\n })\n searchDebug('search.reindex', 'Reindexed all entities to Meilisearch', {\n tenantId: tenantId,\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n errors: result.errors,\n })\n\n // Log to indexer status logs\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: useQueue\n ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of all entities`\n : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${result.entitiesProcessed} entities`,\n tenantId: tenantId,\n organizationId: orgId,\n details: {\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n useQueue,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n },\n )\n\n // Log any batch errors to error logs\n for (const err of result.errors) {\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error: new Error(err.error),\n entityType: err.entityId,\n tenantId: tenantId,\n organizationId: orgId,\n payload: { action, useQueue },\n },\n )\n }\n }\n\n // Get updated stats from all strategies\n const stats = await collectStrategyStats(searchStrategies, tenantId)\n\n return toJson({\n ok: result.success,\n action,\n entityId: entityId ?? null,\n useQueue,\n result: {\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued ?? 0,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n stats,\n })\n } else if (entityId) {\n // Purge specific entity\n await indexableStrategy.purge?.(entityId as EntityId, tenantId)\n searchDebug('search.reindex', 'Purged entity', { strategyId: indexableStrategy.id, entityId, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: `Purged entity ${entityId} from Meilisearch`,\n entityType: entityId,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n } else if (action === 'clear') {\n // Clear all documents but keep index\n if (indexableStrategy.clearIndex) {\n await indexableStrategy.clearIndex(tenantId)\n searchDebug('search.reindex', 'Cleared index', { strategyId: indexableStrategy.id, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: 'Cleared all documents from Meilisearch index',\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n }\n } else {\n // Recreate the entire index\n if (indexableStrategy.recreateIndex) {\n await indexableStrategy.recreateIndex(tenantId)\n searchDebug('search.reindex', 'Recreated index', { strategyId: indexableStrategy.id, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: 'Recreated Meilisearch index',\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n }\n }\n\n // Get updated stats from all strategies\n const stats = await collectStrategyStats(searchStrategies, tenantId)\n\n return toJson({\n ok: true,\n action,\n entityId: entityId ?? null,\n stats,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.reindex', 'Failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n tenantId: tenantId,\n })\n\n // Record error to indexer error logs\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error,\n entityType: entityId ?? null,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n payload: { action, entityId, useQueue },\n },\n )\n\n // Return generic message to client - don't expose internal error details\n return toJson(\n { error: t('search.api.errors.reindexFailed', 'Reindex operation failed. Please try again or contact support.') },\n { status: 500 }\n )\n } finally {\n // Only clear lock immediately if NOT using queue mode\n // When using queue mode, workers update heartbeat and stale detection handles cleanup\n if (!useQueue) {\n await clearReindexLock(knex, tenantId, 'fulltext', auth.orgId ?? null)\n }\n\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = reindexOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { SearchStrategy } from '@open-mercato/shared/modules/search'\nimport type { SearchIndexer } from '@open-mercato/search/indexer'\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 type { EntityManager } from '@mikro-orm/postgresql'\nimport type { Knex } from 'knex'\nimport { searchDebug, searchError } from '../../../../lib/debug'\nimport {\n acquireReindexLock,\n clearReindexLock,\n getReindexLockStatus,\n} from '../../lib/reindex-lock'\nimport {\n completeReindexProgress,\n ensureReindexProgressJob,\n failReindexProgress,\n} from '../../lib/reindex-progress'\nimport { reindexOpenApi } from '../openapi'\n\n/** Strategy with optional stats support */\ntype StrategyWithStats = SearchStrategy & {\n getIndexStats?: (tenantId: string) => Promise<Record<string, unknown> | null>\n clearIndex?: (tenantId: string) => Promise<void>\n recreateIndex?: (tenantId: string) => Promise<void>\n}\n\n/** Collect stats from all strategies that support it */\nasync function collectStrategyStats(\n strategies: StrategyWithStats[],\n tenantId: string\n): Promise<Record<string, Record<string, unknown> | null>> {\n const stats: Record<string, Record<string, unknown> | null> = {}\n for (const strategy of strategies) {\n if (typeof strategy.getIndexStats === 'function') {\n try {\n const isAvailable = await strategy.isAvailable()\n if (isAvailable) {\n stats[strategy.id] = await strategy.getIndexStats(tenantId)\n }\n } catch {\n // Skip strategy if stats collection fails\n }\n }\n }\n return stats\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['search.reindex'] },\n}\n\ntype ReindexAction = 'clear' | 'recreate' | 'reindex'\n\nconst toJson = (payload: Record<string, unknown>, init?: ResponseInit) => NextResponse.json(payload, init)\n\nconst unauthorized = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return await unauthorized()\n }\n\n // Capture tenantId as non-null for TypeScript (we checked above)\n const tenantId = auth.tenantId\n\n let payload: { action?: ReindexAction; entityId?: string; useQueue?: boolean } = {}\n try {\n payload = await req.json()\n } catch {\n // Default to reindex\n }\n\n const action: ReindexAction =\n payload.action === 'clear' ? 'clear' :\n payload.action === 'recreate' ? 'recreate' : 'reindex'\n const entityId = typeof payload.entityId === 'string' ? payload.entityId : undefined\n // Use queue by default (requires queue workers to be running), can be disabled with useQueue: false\n const useQueue = payload.useQueue !== false\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const progressService = container.resolve('progressService') as ProgressService\n const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()\n\n // Check if another fulltext reindex operation is already in progress\n const existingLock = await getReindexLockStatus(knex, tenantId, { type: 'fulltext' })\n if (existingLock) {\n const startedAt = new Date(existingLock.startedAt)\n return NextResponse.json(\n {\n error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),\n lock: {\n type: existingLock.type,\n action: existingLock.action,\n startedAt: existingLock.startedAt,\n elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),\n processedCount: existingLock.processedCount,\n totalCount: existingLock.totalCount,\n },\n },\n { status: 409 }\n )\n }\n\n // Acquire lock before starting the operation\n const { acquired: lockAcquired } = await acquireReindexLock(knex, {\n type: 'fulltext',\n action,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n })\n\n if (!lockAcquired) {\n return NextResponse.json(\n { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },\n { status: 409 }\n )\n }\n\n try {\n // Get all search strategies\n const searchStrategies = (container.resolve('searchStrategies') as StrategyWithStats[] | undefined) ?? []\n\n // Find a strategy that supports index management (clear/recreate)\n const indexableStrategy = searchStrategies.find(\n (s) => typeof s.clearIndex === 'function' || typeof s.recreateIndex === 'function'\n )\n\n if (!indexableStrategy) {\n return toJson(\n { error: t('search.api.errors.noIndexableStrategy', 'No indexable search strategy is configured') },\n { status: 503 }\n )\n }\n\n // Check if strategy is available\n const isAvailable = await indexableStrategy.isAvailable()\n if (!isAvailable) {\n return toJson(\n { error: t('search.api.errors.strategyUnavailable', 'Search strategy is not available') },\n { status: 503 }\n )\n }\n\n // Perform the requested action\n if (action === 'reindex') {\n // Full reindex: recreate index and re-index all data\n const searchIndexer = container.resolve('searchIndexer') as SearchIndexer | undefined\n if (!searchIndexer) {\n return toJson(\n { error: t('search.api.errors.indexerUnavailable', 'Search indexer is not available') },\n { status: 503 }\n )\n }\n\n let result\n const orgId = typeof auth.orgId === 'string' ? auth.orgId : null\n\n // Debug: List enabled entities\n const enabledEntities = searchIndexer.listEnabledEntities()\n searchDebug('search.reindex', 'Starting reindex', {\n tenantId: tenantId,\n orgId,\n enabledEntities,\n entityId: entityId ?? 'all',\n useQueue,\n })\n\n // Log reindex started\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: entityId\n ? `Starting Meilisearch reindex for ${entityId}`\n : `Starting Meilisearch reindex for all entities (${enabledEntities.join(', ')})`,\n entityType: entityId ?? null,\n tenantId: tenantId,\n organizationId: orgId,\n details: { enabledEntities, useQueue },\n },\n )\n\n if (entityId) {\n // Reindex specific entity\n result = await searchIndexer.reindexEntityToFulltext({\n entityId: entityId as EntityId,\n tenantId: tenantId,\n organizationId: orgId,\n recreateIndex: true,\n useQueue,\n onProgress: (progress) => {\n searchDebug('search.reindex', 'Progress', progress)\n // Note: Heartbeat is updated by workers during job processing, not during enqueueing\n },\n })\n searchDebug('search.reindex', 'Reindexed entity to Meilisearch', {\n entityId,\n tenantId: tenantId,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n errors: result.errors,\n })\n\n // Log to indexer status logs\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: useQueue\n ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of ${entityId}`\n : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${entityId}`,\n entityType: entityId,\n tenantId: tenantId,\n organizationId: orgId,\n details: {\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n useQueue,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n },\n )\n\n // Log any batch errors to error logs\n for (const err of result.errors) {\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error: new Error(err.error),\n entityType: err.entityId,\n tenantId: tenantId,\n organizationId: orgId,\n payload: { action, useQueue },\n },\n )\n }\n } else {\n // Reindex all entities\n result = await searchIndexer.reindexAllToFulltext({\n tenantId: tenantId,\n organizationId: orgId,\n recreateIndex: true,\n useQueue,\n onProgress: (progress) => {\n searchDebug('search.reindex', 'Progress', progress)\n // Note: Heartbeat is updated by workers during job processing, not during enqueueing\n },\n })\n searchDebug('search.reindex', 'Reindexed all entities to Meilisearch', {\n tenantId: tenantId,\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n errors: result.errors,\n })\n\n // Log to indexer status logs\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: useQueue\n ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of all entities`\n : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${result.entitiesProcessed} entities`,\n tenantId: tenantId,\n organizationId: orgId,\n details: {\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued,\n useQueue,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n },\n )\n\n // Log any batch errors to error logs\n for (const err of result.errors) {\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error: new Error(err.error),\n entityType: err.entityId,\n tenantId: tenantId,\n organizationId: orgId,\n payload: { action, useQueue },\n },\n )\n }\n }\n\n await ensureReindexProgressJob({\n em,\n progressService,\n type: 'fulltext',\n tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub ?? null,\n totalCount: result.recordsIndexed,\n description: entityId\n ? `Reindex ${entityId} (${useQueue ? 'queued' : 'sync'})`\n : `Reindex all entities (${useQueue ? 'queued' : 'sync'})`,\n })\n if (!useQueue) {\n await completeReindexProgress({\n em,\n progressService,\n type: 'fulltext',\n tenantId,\n organizationId: auth.orgId ?? null,\n resultSummary: {\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued ?? 0,\n errors: result.errors.length,\n },\n })\n }\n\n // Get updated stats from all strategies\n const stats = await collectStrategyStats(searchStrategies, tenantId)\n\n return toJson({\n ok: result.success,\n action,\n entityId: entityId ?? null,\n useQueue,\n result: {\n entitiesProcessed: result.entitiesProcessed,\n recordsIndexed: result.recordsIndexed,\n jobsEnqueued: result.jobsEnqueued ?? 0,\n errors: result.errors.length > 0 ? result.errors : undefined,\n },\n stats,\n })\n } else if (entityId) {\n // Purge specific entity\n await indexableStrategy.purge?.(entityId as EntityId, tenantId)\n searchDebug('search.reindex', 'Purged entity', { strategyId: indexableStrategy.id, entityId, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: `Purged entity ${entityId} from Meilisearch`,\n entityType: entityId,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n } else if (action === 'clear') {\n // Clear all documents but keep index\n if (indexableStrategy.clearIndex) {\n await indexableStrategy.clearIndex(tenantId)\n searchDebug('search.reindex', 'Cleared index', { strategyId: indexableStrategy.id, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: 'Cleared all documents from Meilisearch index',\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n }\n } else {\n // Recreate the entire index\n if (indexableStrategy.recreateIndex) {\n await indexableStrategy.recreateIndex(tenantId)\n searchDebug('search.reindex', 'Recreated index', { strategyId: indexableStrategy.id, tenantId: tenantId })\n\n await recordIndexerLog(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n message: 'Recreated Meilisearch index',\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n },\n )\n }\n }\n\n // Get updated stats from all strategies\n const stats = await collectStrategyStats(searchStrategies, tenantId)\n\n return toJson({\n ok: true,\n action,\n entityId: entityId ?? null,\n stats,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.reindex', 'Failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n tenantId: tenantId,\n })\n\n // Record error to indexer error logs\n await recordIndexerError(\n { em },\n {\n source: 'fulltext',\n handler: 'api:search.reindex',\n error,\n entityType: entityId ?? null,\n tenantId: tenantId,\n organizationId: auth.orgId ?? null,\n payload: { action, entityId, useQueue },\n },\n )\n\n await failReindexProgress({\n em,\n progressService,\n type: 'fulltext',\n tenantId,\n organizationId: auth.orgId ?? null,\n errorMessage: error instanceof Error ? error.message : 'Fulltext reindex failed',\n })\n\n // Return generic message to client - don't expose internal error details\n return toJson(\n { error: t('search.api.errors.reindexFailed', 'Reindex operation failed. Please try again or contact support.') },\n { status: 500 }\n )\n } finally {\n // Only clear lock immediately if NOT using queue mode\n // When using queue mode, workers update heartbeat and stale detection handles cleanup\n if (!useQueue) {\n await clearReindexLock(knex, tenantId, 'fulltext', auth.orgId ?? null)\n }\n\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = reindexOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": ["stats"]
7
7
  }
@@ -6,6 +6,7 @@ import { readApiResultOrThrow } from "@open-mercato/ui/backend/utils/apiCall";
6
6
  import { flash } from "@open-mercato/ui/backend/FlashMessages";
7
7
  import { Button } from "@open-mercato/ui/primitives/button";
8
8
  import { Spinner } from "@open-mercato/ui/primitives/spinner";
9
+ import { useAppEvent } from "@open-mercato/ui/backend/injection/useAppEvent";
9
10
  import { GlobalSearchSection } from "./sections/GlobalSearchSection.js";
10
11
  import { FulltextSearchSection } from "./sections/FulltextSearchSection.js";
11
12
  import { VectorSearchSection } from "./sections/VectorSearchSection.js";
@@ -90,32 +91,18 @@ function SearchSettingsPageClient() {
90
91
  } catch {
91
92
  }
92
93
  }, []);
93
- const wasPollingRef = React.useRef(false);
94
- const pollCountAfterClearRef = React.useRef(0);
95
- React.useEffect(() => {
96
- const hasFulltextLock = settings?.fulltextReindexLock !== null;
97
- const hasVectorLock = settings?.vectorReindexLock !== null;
98
- const shouldPoll = hasFulltextLock || hasVectorLock || wasPollingRef.current && pollCountAfterClearRef.current < 3;
99
- if (!shouldPoll) {
100
- wasPollingRef.current = false;
101
- pollCountAfterClearRef.current = 0;
102
- return;
103
- }
104
- if (hasFulltextLock || hasVectorLock) {
105
- wasPollingRef.current = true;
106
- pollCountAfterClearRef.current = 0;
107
- }
108
- const pollInterval = setInterval(() => {
109
- if (!hasFulltextLock && !hasVectorLock) {
110
- pollCountAfterClearRef.current += 1;
111
- }
112
- refreshStatsOnly();
113
- if (hasVectorLock) {
114
- refreshEmbeddingStatsOnly();
115
- }
116
- }, 3e3);
117
- return () => clearInterval(pollInterval);
118
- }, [settings?.fulltextReindexLock, settings?.vectorReindexLock, refreshStatsOnly, refreshEmbeddingStatsOnly]);
94
+ useAppEvent("progress.job.updated", () => {
95
+ void refreshStatsOnly();
96
+ void refreshEmbeddingStatsOnly();
97
+ }, [refreshStatsOnly, refreshEmbeddingStatsOnly]);
98
+ useAppEvent("progress.job.completed", () => {
99
+ void refreshStatsOnly();
100
+ void refreshEmbeddingStatsOnly();
101
+ }, [refreshStatsOnly, refreshEmbeddingStatsOnly]);
102
+ useAppEvent("om:bridge:reconnected", () => {
103
+ void refreshStatsOnly();
104
+ void refreshEmbeddingStatsOnly();
105
+ }, [refreshStatsOnly, refreshEmbeddingStatsOnly]);
119
106
  const fetchEmbeddingSettings = React.useCallback(async () => {
120
107
  setEmbeddingLoading(true);
121
108
  try {