@open-mercato/search 0.6.6-develop.6429.1.2fba7258d1 → 0.6.6-develop.6450.1.b493fb430c

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.
@@ -6,6 +6,15 @@ import { cancelReindexProgress } from "../../../../lib/reindex-progress.js";
6
6
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
7
7
  import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
8
8
  import { embeddingsReindexCancelOpenApi } from "../../../openapi.js";
9
+ async function disposeContainer(container) {
10
+ try {
11
+ const disposable = container;
12
+ if (typeof disposable.dispose === "function") {
13
+ await disposable.dispose();
14
+ }
15
+ } catch {
16
+ }
17
+ }
9
18
  const metadata = {
10
19
  POST: { requireAuth: true, requireFeatures: ["search.embeddings.manage"] }
11
20
  };
@@ -26,11 +35,21 @@ async function POST(req) {
26
35
  }
27
36
  let jobsRemoved = 0;
28
37
  if (queue) {
38
+ if (typeof queue.removeQueuedJobsByScope !== "function") {
39
+ await disposeContainer(container);
40
+ return NextResponse.json({
41
+ error: t("search.api.errors.scopedQueueCancelUnavailable", "Scoped queue cancellation is not available.")
42
+ }, { status: 503 });
43
+ }
29
44
  try {
30
- const countsBefore = await queue.getJobCounts();
31
- jobsRemoved = countsBefore.waiting + countsBefore.active;
32
- await queue.clear();
45
+ const scope = typeof auth.orgId === "string" ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ["batch-index"] } : { tenantId: auth.tenantId, jobTypes: ["batch-index"] };
46
+ const result = await queue.removeQueuedJobsByScope(scope);
47
+ jobsRemoved = result.removed;
33
48
  } catch {
49
+ await disposeContainer(container);
50
+ return NextResponse.json({
51
+ error: t("search.api.errors.scopedQueueCancelFailed", "Failed to cancel queued reindex jobs.")
52
+ }, { status: 503 });
34
53
  }
35
54
  }
36
55
  await clearReindexLock(db, auth.tenantId, "vector", auth.orgId ?? null);
@@ -57,13 +76,7 @@ async function POST(req) {
57
76
  );
58
77
  } catch {
59
78
  }
60
- try {
61
- const disposable = container;
62
- if (typeof disposable.dispose === "function") {
63
- await disposable.dispose();
64
- }
65
- } catch {
66
- }
79
+ await disposeContainer(container);
67
80
  return NextResponse.json({
68
81
  ok: true,
69
82
  jobsRemoved
@@ -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'\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { Kysely } from 'kysely'\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 db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\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(db, 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;AAMnC,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,KAAM,GAAmD,UAAU;AAEzE,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,IAAI,KAAK,UAAU,UAAU,KAAK,SAAS,IAAI;AACtE,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;",
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'\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { Kysely } from 'kysely'\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\nasync function disposeContainer(container: unknown): Promise<void> {\n try {\n const disposable = container as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n}\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 db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\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 if (typeof queue.removeQueuedJobsByScope !== 'function') {\n await disposeContainer(container)\n return NextResponse.json({\n error: t('search.api.errors.scopedQueueCancelUnavailable', 'Scoped queue cancellation is not available.'),\n }, { status: 503 })\n }\n\n try {\n const scope = typeof auth.orgId === 'string'\n ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ['batch-index'] }\n : { tenantId: auth.tenantId, jobTypes: ['batch-index'] }\n const result = await queue.removeQueuedJobsByScope(scope)\n jobsRemoved = result.removed\n } catch {\n await disposeContainer(container)\n return NextResponse.json({\n error: t('search.api.errors.scopedQueueCancelFailed', 'Failed to cancel queued reindex jobs.'),\n }, { status: 503 })\n }\n }\n\n await clearReindexLock(db, 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 await disposeContainer(container)\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;AAMnC,SAAS,wBAAwB;AACjC,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,sCAAsC;AAE/C,eAAe,iBAAiB,WAAmC;AACjE,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,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,KAAM,GAAmD,UAAU;AAEzE,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,kBAAkB;AAAA,EACrD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI,OAAO,MAAM,4BAA4B,YAAY;AACvD,YAAM,iBAAiB,SAAS;AAChC,aAAO,aAAa,KAAK;AAAA,QACvB,OAAO,EAAE,kDAAkD,6CAA6C;AAAA,MAC1G,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACpB;AAEA,QAAI;AACF,YAAM,QAAQ,OAAO,KAAK,UAAU,WAChC,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,OAAO,UAAU,CAAC,aAAa,EAAE,IACjF,EAAE,UAAU,KAAK,UAAU,UAAU,CAAC,aAAa,EAAE;AACzD,YAAM,SAAS,MAAM,MAAM,wBAAwB,KAAK;AACxD,oBAAc,OAAO;AAAA,IACvB,QAAQ;AACN,YAAM,iBAAiB,SAAS;AAChC,aAAO,aAAa,KAAK;AAAA,QACvB,OAAO,EAAE,6CAA6C,uCAAuC;AAAA,MAC/F,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,iBAAiB,IAAI,KAAK,UAAU,UAAU,KAAK,SAAS,IAAI;AACtE,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,QAAM,iBAAiB,SAAS;AAEhC,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
6
6
  "names": ["em"]
7
7
  }
@@ -6,6 +6,15 @@ import { cancelReindexProgress } from "../../../lib/reindex-progress.js";
6
6
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
7
7
  import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
8
8
  import { reindexCancelOpenApi } from "../../openapi.js";
9
+ async function disposeContainer(container) {
10
+ try {
11
+ const disposable = container;
12
+ if (typeof disposable.dispose === "function") {
13
+ await disposable.dispose();
14
+ }
15
+ } catch {
16
+ }
17
+ }
9
18
  const metadata = {
10
19
  POST: { requireAuth: true, requireFeatures: ["search.reindex"] }
11
20
  };
@@ -26,11 +35,21 @@ async function POST(req) {
26
35
  }
27
36
  let jobsRemoved = 0;
28
37
  if (queue) {
38
+ if (typeof queue.removeQueuedJobsByScope !== "function") {
39
+ await disposeContainer(container);
40
+ return NextResponse.json({
41
+ error: t("search.api.errors.scopedQueueCancelUnavailable", "Scoped queue cancellation is not available.")
42
+ }, { status: 503 });
43
+ }
29
44
  try {
30
- const countsBefore = await queue.getJobCounts();
31
- jobsRemoved = countsBefore.waiting + countsBefore.active;
32
- await queue.clear();
45
+ const scope = typeof auth.orgId === "string" ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ["batch-index"] } : { tenantId: auth.tenantId, jobTypes: ["batch-index"] };
46
+ const result = await queue.removeQueuedJobsByScope(scope);
47
+ jobsRemoved = result.removed;
33
48
  } catch {
49
+ await disposeContainer(container);
50
+ return NextResponse.json({
51
+ error: t("search.api.errors.scopedQueueCancelFailed", "Failed to cancel queued reindex jobs.")
52
+ }, { status: 503 });
34
53
  }
35
54
  }
36
55
  await clearReindexLock(db, auth.tenantId, "fulltext", auth.orgId ?? null);
@@ -57,13 +76,7 @@ async function POST(req) {
57
76
  );
58
77
  } catch {
59
78
  }
60
- try {
61
- const disposable = container;
62
- if (typeof disposable.dispose === "function") {
63
- await disposable.dispose();
64
- }
65
- } catch {
66
- }
79
+ await disposeContainer(container);
67
80
  return NextResponse.json({
68
81
  ok: true,
69
82
  jobsRemoved
@@ -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'\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { Kysely } from 'kysely'\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 db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\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(db, 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;AAMnC,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,KAAM,GAAmD,UAAU;AAEzE,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,IAAI,KAAK,UAAU,YAAY,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,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'\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { Kysely } from 'kysely'\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\nasync function disposeContainer(container: unknown): Promise<void> {\n try {\n const disposable = container as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n } catch {\n // Ignore disposal errors\n }\n}\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 db = (em as unknown as { getKysely: () => Kysely<any> }).getKysely()\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 if (typeof queue.removeQueuedJobsByScope !== 'function') {\n await disposeContainer(container)\n return NextResponse.json({\n error: t('search.api.errors.scopedQueueCancelUnavailable', 'Scoped queue cancellation is not available.'),\n }, { status: 503 })\n }\n\n try {\n const scope = typeof auth.orgId === 'string'\n ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ['batch-index'] }\n : { tenantId: auth.tenantId, jobTypes: ['batch-index'] }\n const result = await queue.removeQueuedJobsByScope(scope)\n jobsRemoved = result.removed\n } catch {\n await disposeContainer(container)\n return NextResponse.json({\n error: t('search.api.errors.scopedQueueCancelFailed', 'Failed to cancel queued reindex jobs.'),\n }, { status: 503 })\n }\n }\n\n await clearReindexLock(db, 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 await disposeContainer(container)\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;AAMnC,SAAS,wBAAwB;AACjC,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,wBAAwB;AACjC,SAAS,4BAA4B;AAErC,eAAe,iBAAiB,WAAmC;AACjE,MAAI;AACF,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEO,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,KAAM,GAAmD,UAAU;AAEzE,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,QAAe,oBAAoB;AAAA,EACvD,QAAQ;AAAA,EAER;AAEA,MAAI,cAAc;AAClB,MAAI,OAAO;AACT,QAAI,OAAO,MAAM,4BAA4B,YAAY;AACvD,YAAM,iBAAiB,SAAS;AAChC,aAAO,aAAa,KAAK;AAAA,QACvB,OAAO,EAAE,kDAAkD,6CAA6C;AAAA,MAC1G,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACpB;AAEA,QAAI;AACF,YAAM,QAAQ,OAAO,KAAK,UAAU,WAChC,EAAE,UAAU,KAAK,UAAU,gBAAgB,KAAK,OAAO,UAAU,CAAC,aAAa,EAAE,IACjF,EAAE,UAAU,KAAK,UAAU,UAAU,CAAC,aAAa,EAAE;AACzD,YAAM,SAAS,MAAM,MAAM,wBAAwB,KAAK;AACxD,oBAAc,OAAO;AAAA,IACvB,QAAQ;AACN,YAAM,iBAAiB,SAAS;AAChC,aAAO,aAAa,KAAK;AAAA,QACvB,OAAO,EAAE,6CAA6C,uCAAuC;AAAA,MAC/F,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,iBAAiB,IAAI,KAAK,UAAU,YAAY,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,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,QAAM,iBAAiB,SAAS;AAEhC,SAAO,aAAa,KAAK;AAAA,IACvB,IAAI;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEO,MAAM,UAAU;",
6
6
  "names": ["em"]
7
7
  }
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Fehler beim Neuerstellen der Vektortabelle mit neuer Dimension.",
18
18
  "search.api.errors.reindexFailed": "Neuindizierung fehlgeschlagen.",
19
19
  "search.api.errors.reindexInProgress": "Eine Neuindizierung wird bereits durchgeführt.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Die eingereihten Reindexierungsaufträge konnten nicht abgebrochen werden.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Die bereichsbezogene Warteschlangenstornierung ist nicht verfügbar.",
20
22
  "search.api.errors.searchFailed": "Suchvorgang fehlgeschlagen.",
21
23
  "search.api.errors.serviceUnavailable": "Suchdienst ist nicht verfügbar.",
22
24
  "search.api.errors.strategyUnavailable": "Angeforderte Suchstrategie ist nicht verfügbar.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Failed to recreate vector table with new dimension.",
18
18
  "search.api.errors.reindexFailed": "Reindex operation failed.",
19
19
  "search.api.errors.reindexInProgress": "A reindex is already in progress.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Failed to cancel queued reindex jobs.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Scoped queue cancellation is not available.",
20
22
  "search.api.errors.searchFailed": "Search operation failed.",
21
23
  "search.api.errors.serviceUnavailable": "Search service is unavailable.",
22
24
  "search.api.errors.strategyUnavailable": "Requested search strategy is unavailable.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "No se pudo recrear la tabla vectorial con la nueva dimensión.",
18
18
  "search.api.errors.reindexFailed": "La operación de reindexación falló.",
19
19
  "search.api.errors.reindexInProgress": "Ya hay una reindexación en progreso.",
20
+ "search.api.errors.scopedQueueCancelFailed": "No se pudieron cancelar los trabajos de reindexación en cola.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "La cancelación de cola con alcance no está disponible.",
20
22
  "search.api.errors.searchFailed": "La operación de búsqueda falló.",
21
23
  "search.api.errors.serviceUnavailable": "El servicio de búsqueda no está disponible.",
22
24
  "search.api.errors.strategyUnavailable": "La estrategia de búsqueda solicitada no está disponible.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Nie udało się odtworzyć tabeli wektorowej z nowym wymiarem.",
18
18
  "search.api.errors.reindexFailed": "Operacja reindeksacji nie powiodła się.",
19
19
  "search.api.errors.reindexInProgress": "Reindeksacja jest już w toku.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Nie udało się anulować zakolejkowanych zadań reindeksacji.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Zakresowe anulowanie kolejki jest niedostępne.",
20
22
  "search.api.errors.searchFailed": "Operacja wyszukiwania nie powiodła się.",
21
23
  "search.api.errors.serviceUnavailable": "Usługa wyszukiwania jest niedostępna.",
22
24
  "search.api.errors.strategyUnavailable": "Żądana strategia wyszukiwania jest niedostępna.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.6-develop.6429.1.2fba7258d1",
3
+ "version": "0.6.6-develop.6450.1.b493fb430c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.6-develop.6429.1.2fba7258d1",
131
- "@open-mercato/queue": "0.6.6-develop.6429.1.2fba7258d1",
132
- "@open-mercato/shared": "0.6.6-develop.6429.1.2fba7258d1"
130
+ "@open-mercato/core": "0.6.6-develop.6450.1.b493fb430c",
131
+ "@open-mercato/queue": "0.6.6-develop.6450.1.b493fb430c",
132
+ "@open-mercato/shared": "0.6.6-develop.6450.1.b493fb430c"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -35,13 +35,16 @@ jest.mock('../../lib/reindex-lock', () => ({
35
35
  const mockEnsureReindexProgressJob = jest.fn().mockResolvedValue('progress-1')
36
36
  const mockCompleteReindexProgress = jest.fn().mockResolvedValue(undefined)
37
37
  const mockFailReindexProgress = jest.fn().mockResolvedValue(undefined)
38
+ const mockCancelReindexProgress = jest.fn().mockResolvedValue(undefined)
38
39
  jest.mock('../../lib/reindex-progress', () => ({
39
40
  ensureReindexProgressJob: (...args: unknown[]) => mockEnsureReindexProgressJob(...args),
40
41
  completeReindexProgress: (...args: unknown[]) => mockCompleteReindexProgress(...args),
41
42
  failReindexProgress: (...args: unknown[]) => mockFailReindexProgress(...args),
43
+ cancelReindexProgress: (...args: unknown[]) => mockCancelReindexProgress(...args),
42
44
  }))
43
45
 
44
46
  import { POST as vectorReindexPost } from '../embeddings/reindex/route'
47
+ import { POST as vectorReindexCancelPost } from '../embeddings/reindex/cancel/route'
45
48
 
46
49
  describe('POST /api/search/embeddings/reindex', () => {
47
50
  beforeEach(() => {
@@ -127,4 +130,108 @@ describe('POST /api/search/embeddings/reindex', () => {
127
130
  expect(mockClearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'vector', 'org-456')
128
131
  expect(mockContainer.dispose).toHaveBeenCalled()
129
132
  })
133
+
134
+ test('cancel removes only current tenant and organization vector queue jobs', async () => {
135
+ mockGetAuthFromRequest.mockResolvedValue({
136
+ tenantId: 'tenant-123',
137
+ orgId: 'org-456',
138
+ sub: 'user-789',
139
+ })
140
+
141
+ const mockDb = { db: true }
142
+ const mockEm = { getKysely: jest.fn(() => mockDb) }
143
+ const mockProgressService = { progress: true }
144
+ const mockQueue = {
145
+ getJobCounts: jest.fn().mockResolvedValue({ waiting: 3, active: 1, completed: 0, failed: 0 }),
146
+ clear: jest.fn().mockResolvedValue({ removed: 4 }),
147
+ removeQueuedJobsByScope: jest.fn().mockResolvedValue({ removed: 2 }),
148
+ }
149
+ const mockContainer = {
150
+ resolve: jest.fn((name: string) => {
151
+ if (name === 'em') return mockEm
152
+ if (name === 'progressService') return mockProgressService
153
+ if (name === 'vectorIndexQueue') return mockQueue
154
+ throw new Error(`Unknown service: ${name}`)
155
+ }),
156
+ dispose: jest.fn().mockResolvedValue(undefined),
157
+ }
158
+ mockCreateRequestContainer.mockResolvedValue(mockContainer)
159
+
160
+ const res = await vectorReindexCancelPost(new Request(
161
+ 'http://localhost/api/search/embeddings/reindex/cancel',
162
+ { method: 'POST' },
163
+ ))
164
+ const body = await res.json()
165
+
166
+ expect(res.status).toBe(200)
167
+ expect(body).toEqual({ ok: true, jobsRemoved: 2 })
168
+ expect(mockQueue.removeQueuedJobsByScope).toHaveBeenCalledWith({
169
+ tenantId: 'tenant-123',
170
+ organizationId: 'org-456',
171
+ jobTypes: ['batch-index'],
172
+ })
173
+ expect(mockQueue.clear).not.toHaveBeenCalled()
174
+ expect(mockClearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'vector', 'org-456')
175
+ expect(mockCancelReindexProgress).toHaveBeenCalledWith(expect.objectContaining({
176
+ em: mockEm,
177
+ progressService: mockProgressService,
178
+ type: 'vector',
179
+ tenantId: 'tenant-123',
180
+ organizationId: 'org-456',
181
+ userId: 'user-789',
182
+ }))
183
+ expect(mockRecordIndexerLog).toHaveBeenCalledWith(
184
+ { em: mockEm },
185
+ expect.objectContaining({
186
+ source: 'vector',
187
+ details: { jobsRemoved: 2 },
188
+ tenantId: 'tenant-123',
189
+ organizationId: 'org-456',
190
+ }),
191
+ )
192
+ expect(mockContainer.dispose).toHaveBeenCalled()
193
+ })
194
+
195
+ test('cancel fails closed when scoped vector queue removal fails', async () => {
196
+ mockGetAuthFromRequest.mockResolvedValue({
197
+ tenantId: 'tenant-123',
198
+ orgId: 'org-456',
199
+ sub: 'user-789',
200
+ })
201
+
202
+ const mockDb = { db: true }
203
+ const mockEm = { getKysely: jest.fn(() => mockDb) }
204
+ const mockQueue = {
205
+ clear: jest.fn().mockResolvedValue({ removed: 4 }),
206
+ removeQueuedJobsByScope: jest.fn().mockRejectedValue(new Error('redis unavailable')),
207
+ }
208
+ const mockContainer = {
209
+ resolve: jest.fn((name: string) => {
210
+ if (name === 'em') return mockEm
211
+ if (name === 'progressService') return { progress: true }
212
+ if (name === 'vectorIndexQueue') return mockQueue
213
+ throw new Error(`Unknown service: ${name}`)
214
+ }),
215
+ dispose: jest.fn().mockResolvedValue(undefined),
216
+ }
217
+ mockCreateRequestContainer.mockResolvedValue(mockContainer)
218
+
219
+ const res = await vectorReindexCancelPost(new Request(
220
+ 'http://localhost/api/search/embeddings/reindex/cancel',
221
+ { method: 'POST' },
222
+ ))
223
+ const body = await res.json()
224
+
225
+ expect(res.status).toBe(503)
226
+ expect(body.error).toBe('Failed to cancel queued reindex jobs.')
227
+ expect(mockQueue.removeQueuedJobsByScope).toHaveBeenCalledWith({
228
+ tenantId: 'tenant-123',
229
+ organizationId: 'org-456',
230
+ jobTypes: ['batch-index'],
231
+ })
232
+ expect(mockQueue.clear).not.toHaveBeenCalled()
233
+ expect(mockClearReindexLock).not.toHaveBeenCalled()
234
+ expect(mockCancelReindexProgress).not.toHaveBeenCalled()
235
+ expect(mockContainer.dispose).toHaveBeenCalled()
236
+ })
130
237
  })
@@ -35,13 +35,16 @@ jest.mock('../../lib/reindex-lock', () => ({
35
35
  const mockEnsureReindexProgressJob = jest.fn().mockResolvedValue('progress-1')
36
36
  const mockCompleteReindexProgress = jest.fn().mockResolvedValue(undefined)
37
37
  const mockFailReindexProgress = jest.fn().mockResolvedValue(undefined)
38
+ const mockCancelReindexProgress = jest.fn().mockResolvedValue(undefined)
38
39
  jest.mock('../../lib/reindex-progress', () => ({
39
40
  ensureReindexProgressJob: (...args: unknown[]) => mockEnsureReindexProgressJob(...args),
40
41
  completeReindexProgress: (...args: unknown[]) => mockCompleteReindexProgress(...args),
41
42
  failReindexProgress: (...args: unknown[]) => mockFailReindexProgress(...args),
43
+ cancelReindexProgress: (...args: unknown[]) => mockCancelReindexProgress(...args),
42
44
  }))
43
45
 
44
46
  import { POST as fulltextReindexPost } from '../reindex/route'
47
+ import { POST as fulltextReindexCancelPost } from '../reindex/cancel/route'
45
48
 
46
49
  function mockAuth() {
47
50
  mockGetAuthFromRequest.mockResolvedValue({
@@ -162,4 +165,88 @@ describe('POST /api/search/reindex', () => {
162
165
  expect(mockClearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
163
166
  expect(mockContainer.dispose).toHaveBeenCalled()
164
167
  })
168
+
169
+ test('cancel removes only current tenant and organization fulltext queue jobs', async () => {
170
+ const mockDb = { db: true }
171
+ const mockEm = { getKysely: jest.fn(() => mockDb) }
172
+ const mockQueue = {
173
+ getJobCounts: jest.fn().mockResolvedValue({ waiting: 3, active: 1, completed: 0, failed: 0 }),
174
+ clear: jest.fn().mockResolvedValue({ removed: 4 }),
175
+ removeQueuedJobsByScope: jest.fn().mockResolvedValue({ removed: 2 }),
176
+ }
177
+ const mockProgressService = { progress: true }
178
+ const mockContainer = {
179
+ resolve: jest.fn((name: string) => {
180
+ if (name === 'em') return mockEm
181
+ if (name === 'progressService') return mockProgressService
182
+ if (name === 'fulltextIndexQueue') return mockQueue
183
+ throw new Error(`Unknown service: ${name}`)
184
+ }),
185
+ dispose: jest.fn().mockResolvedValue(undefined),
186
+ }
187
+ mockCreateRequestContainer.mockResolvedValue(mockContainer)
188
+
189
+ const res = await fulltextReindexCancelPost(new Request('http://localhost/api/search/reindex/cancel', {
190
+ method: 'POST',
191
+ }))
192
+ const body = await res.json()
193
+
194
+ expect(res.status).toBe(200)
195
+ expect(body).toEqual({ ok: true, jobsRemoved: 2 })
196
+ expect(mockQueue.removeQueuedJobsByScope).toHaveBeenCalledWith({
197
+ tenantId: 'tenant-123',
198
+ organizationId: 'org-456',
199
+ jobTypes: ['batch-index'],
200
+ })
201
+ expect(mockQueue.clear).not.toHaveBeenCalled()
202
+ expect(mockClearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
203
+ expect(mockCancelReindexProgress).toHaveBeenCalledWith(expect.objectContaining({
204
+ em: mockEm,
205
+ progressService: mockProgressService,
206
+ type: 'fulltext',
207
+ tenantId: 'tenant-123',
208
+ organizationId: 'org-456',
209
+ userId: 'user-789',
210
+ }))
211
+ expect(mockRecordIndexerLog).toHaveBeenCalledWith(
212
+ { em: mockEm },
213
+ expect.objectContaining({
214
+ source: 'fulltext',
215
+ details: { jobsRemoved: 2 },
216
+ tenantId: 'tenant-123',
217
+ organizationId: 'org-456',
218
+ }),
219
+ )
220
+ expect(mockContainer.dispose).toHaveBeenCalled()
221
+ })
222
+
223
+ test('cancel fails closed when scoped fulltext queue removal is unavailable', async () => {
224
+ const mockDb = { db: true }
225
+ const mockEm = { getKysely: jest.fn(() => mockDb) }
226
+ const mockQueue = {
227
+ clear: jest.fn().mockResolvedValue({ removed: 4 }),
228
+ }
229
+ const mockContainer = {
230
+ resolve: jest.fn((name: string) => {
231
+ if (name === 'em') return mockEm
232
+ if (name === 'progressService') return { progress: true }
233
+ if (name === 'fulltextIndexQueue') return mockQueue
234
+ throw new Error(`Unknown service: ${name}`)
235
+ }),
236
+ dispose: jest.fn().mockResolvedValue(undefined),
237
+ }
238
+ mockCreateRequestContainer.mockResolvedValue(mockContainer)
239
+
240
+ const res = await fulltextReindexCancelPost(new Request('http://localhost/api/search/reindex/cancel', {
241
+ method: 'POST',
242
+ }))
243
+ const body = await res.json()
244
+
245
+ expect(res.status).toBe(503)
246
+ expect(body.error).toBe('Scoped queue cancellation is not available.')
247
+ expect(mockQueue.clear).not.toHaveBeenCalled()
248
+ expect(mockClearReindexLock).not.toHaveBeenCalled()
249
+ expect(mockCancelReindexProgress).not.toHaveBeenCalled()
250
+ expect(mockContainer.dispose).toHaveBeenCalled()
251
+ })
165
252
  })
@@ -12,6 +12,17 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
12
  import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
13
13
  import { embeddingsReindexCancelOpenApi } from '../../../openapi'
14
14
 
15
+ async function disposeContainer(container: unknown): Promise<void> {
16
+ try {
17
+ const disposable = container as { dispose?: () => Promise<void> }
18
+ if (typeof disposable.dispose === 'function') {
19
+ await disposable.dispose()
20
+ }
21
+ } catch {
22
+ // Ignore disposal errors
23
+ }
24
+ }
25
+
15
26
  export const metadata = {
16
27
  POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },
17
28
  }
@@ -37,12 +48,24 @@ export async function POST(req: Request) {
37
48
 
38
49
  let jobsRemoved = 0
39
50
  if (queue) {
51
+ if (typeof queue.removeQueuedJobsByScope !== 'function') {
52
+ await disposeContainer(container)
53
+ return NextResponse.json({
54
+ error: t('search.api.errors.scopedQueueCancelUnavailable', 'Scoped queue cancellation is not available.'),
55
+ }, { status: 503 })
56
+ }
57
+
40
58
  try {
41
- const countsBefore = await queue.getJobCounts()
42
- jobsRemoved = countsBefore.waiting + countsBefore.active
43
- await queue.clear()
59
+ const scope = typeof auth.orgId === 'string'
60
+ ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ['batch-index'] }
61
+ : { tenantId: auth.tenantId, jobTypes: ['batch-index'] }
62
+ const result = await queue.removeQueuedJobsByScope(scope)
63
+ jobsRemoved = result.removed
44
64
  } catch {
45
- // Queue clear failed - continue to clear lock
65
+ await disposeContainer(container)
66
+ return NextResponse.json({
67
+ error: t('search.api.errors.scopedQueueCancelFailed', 'Failed to cancel queued reindex jobs.'),
68
+ }, { status: 503 })
46
69
  }
47
70
  }
48
71
 
@@ -74,14 +97,7 @@ export async function POST(req: Request) {
74
97
  // Logging failure should not fail the cancel operation
75
98
  }
76
99
 
77
- try {
78
- const disposable = container as unknown as { dispose?: () => Promise<void> }
79
- if (typeof disposable.dispose === 'function') {
80
- await disposable.dispose()
81
- }
82
- } catch {
83
- // Ignore disposal errors
84
- }
100
+ await disposeContainer(container)
85
101
 
86
102
  return NextResponse.json({
87
103
  ok: true,
@@ -12,6 +12,17 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
12
  import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
13
13
  import { reindexCancelOpenApi } from '../../openapi'
14
14
 
15
+ async function disposeContainer(container: unknown): Promise<void> {
16
+ try {
17
+ const disposable = container as { dispose?: () => Promise<void> }
18
+ if (typeof disposable.dispose === 'function') {
19
+ await disposable.dispose()
20
+ }
21
+ } catch {
22
+ // Ignore disposal errors
23
+ }
24
+ }
25
+
15
26
  export const metadata = {
16
27
  POST: { requireAuth: true, requireFeatures: ['search.reindex'] },
17
28
  }
@@ -37,12 +48,24 @@ export async function POST(req: Request) {
37
48
 
38
49
  let jobsRemoved = 0
39
50
  if (queue) {
51
+ if (typeof queue.removeQueuedJobsByScope !== 'function') {
52
+ await disposeContainer(container)
53
+ return NextResponse.json({
54
+ error: t('search.api.errors.scopedQueueCancelUnavailable', 'Scoped queue cancellation is not available.'),
55
+ }, { status: 503 })
56
+ }
57
+
40
58
  try {
41
- const countsBefore = await queue.getJobCounts()
42
- jobsRemoved = countsBefore.waiting + countsBefore.active
43
- await queue.clear()
59
+ const scope = typeof auth.orgId === 'string'
60
+ ? { tenantId: auth.tenantId, organizationId: auth.orgId, jobTypes: ['batch-index'] }
61
+ : { tenantId: auth.tenantId, jobTypes: ['batch-index'] }
62
+ const result = await queue.removeQueuedJobsByScope(scope)
63
+ jobsRemoved = result.removed
44
64
  } catch {
45
- // Queue clear failed - continue to clear lock
65
+ await disposeContainer(container)
66
+ return NextResponse.json({
67
+ error: t('search.api.errors.scopedQueueCancelFailed', 'Failed to cancel queued reindex jobs.'),
68
+ }, { status: 503 })
46
69
  }
47
70
  }
48
71
 
@@ -74,14 +97,7 @@ export async function POST(req: Request) {
74
97
  // Logging failure should not fail the cancel operation
75
98
  }
76
99
 
77
- try {
78
- const disposable = container as unknown as { dispose?: () => Promise<void> }
79
- if (typeof disposable.dispose === 'function') {
80
- await disposable.dispose()
81
- }
82
- } catch {
83
- // Ignore disposal errors
84
- }
100
+ await disposeContainer(container)
85
101
 
86
102
  return NextResponse.json({
87
103
  ok: true,
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Fehler beim Neuerstellen der Vektortabelle mit neuer Dimension.",
18
18
  "search.api.errors.reindexFailed": "Neuindizierung fehlgeschlagen.",
19
19
  "search.api.errors.reindexInProgress": "Eine Neuindizierung wird bereits durchgeführt.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Die eingereihten Reindexierungsaufträge konnten nicht abgebrochen werden.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Die bereichsbezogene Warteschlangenstornierung ist nicht verfügbar.",
20
22
  "search.api.errors.searchFailed": "Suchvorgang fehlgeschlagen.",
21
23
  "search.api.errors.serviceUnavailable": "Suchdienst ist nicht verfügbar.",
22
24
  "search.api.errors.strategyUnavailable": "Angeforderte Suchstrategie ist nicht verfügbar.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Failed to recreate vector table with new dimension.",
18
18
  "search.api.errors.reindexFailed": "Reindex operation failed.",
19
19
  "search.api.errors.reindexInProgress": "A reindex is already in progress.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Failed to cancel queued reindex jobs.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Scoped queue cancellation is not available.",
20
22
  "search.api.errors.searchFailed": "Search operation failed.",
21
23
  "search.api.errors.serviceUnavailable": "Search service is unavailable.",
22
24
  "search.api.errors.strategyUnavailable": "Requested search strategy is unavailable.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "No se pudo recrear la tabla vectorial con la nueva dimensión.",
18
18
  "search.api.errors.reindexFailed": "La operación de reindexación falló.",
19
19
  "search.api.errors.reindexInProgress": "Ya hay una reindexación en progreso.",
20
+ "search.api.errors.scopedQueueCancelFailed": "No se pudieron cancelar los trabajos de reindexación en cola.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "La cancelación de cola con alcance no está disponible.",
20
22
  "search.api.errors.searchFailed": "La operación de búsqueda falló.",
21
23
  "search.api.errors.serviceUnavailable": "El servicio de búsqueda no está disponible.",
22
24
  "search.api.errors.strategyUnavailable": "La estrategia de búsqueda solicitada no está disponible.",
@@ -17,6 +17,8 @@
17
17
  "search.api.errors.recreateFailed": "Nie udało się odtworzyć tabeli wektorowej z nowym wymiarem.",
18
18
  "search.api.errors.reindexFailed": "Operacja reindeksacji nie powiodła się.",
19
19
  "search.api.errors.reindexInProgress": "Reindeksacja jest już w toku.",
20
+ "search.api.errors.scopedQueueCancelFailed": "Nie udało się anulować zakolejkowanych zadań reindeksacji.",
21
+ "search.api.errors.scopedQueueCancelUnavailable": "Zakresowe anulowanie kolejki jest niedostępne.",
20
22
  "search.api.errors.searchFailed": "Operacja wyszukiwania nie powiodła się.",
21
23
  "search.api.errors.serviceUnavailable": "Usługa wyszukiwania jest niedostępna.",
22
24
  "search.api.errors.strategyUnavailable": "Żądana strategia wyszukiwania jest niedostępna.",