@open-mercato/core 0.8.1-develop.7225.1.73190a4a95 → 0.8.1-develop.7228.1.b7ea09d2d3

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 (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  3. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  4. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +11 -7
  5. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  6. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  7. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  8. package/dist/modules/communication_channels/index.js +2 -1
  9. package/dist/modules/communication_channels/index.js.map +2 -2
  10. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  11. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  12. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  13. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  14. package/dist/modules/query_index/lib/engine.js +19 -1
  15. package/dist/modules/query_index/lib/engine.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  18. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +11 -7
  19. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  20. package/src/modules/communication_channels/i18n/de.json +2 -2
  21. package/src/modules/communication_channels/i18n/en.json +2 -2
  22. package/src/modules/communication_channels/i18n/es.json +2 -2
  23. package/src/modules/communication_channels/i18n/ko.json +2 -2
  24. package/src/modules/communication_channels/i18n/pl.json +2 -2
  25. package/src/modules/communication_channels/index.ts +1 -0
  26. package/src/modules/communication_channels/lib/adapter.ts +8 -2
  27. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  28. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  29. package/src/modules/query_index/lib/engine.ts +23 -1
@@ -0,0 +1,69 @@
1
+ const IMPORT_HISTORY_DEFAULT_SINCE_DAYS = 30;
2
+ const IMPORT_HISTORY_DEFAULT_MAX_MESSAGES = 1e3;
3
+ const IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK = 3650;
4
+ const IMPORT_HISTORY_MAX_MESSAGES_FALLBACK = 5e4;
5
+ const IMPORT_HISTORY_MAX_CONTACT_EMAILS = 200;
6
+ const IMPORT_HISTORY_MIN_PAGE_BUDGET = 100;
7
+ const IMPORT_HISTORY_ABSOLUTE_MAX_PAGES = 1e4;
8
+ const IMPORT_HISTORY_MAX_EMPTY_PAGES = 10;
9
+ const IMPORT_HISTORY_MAX_SINCE_DAYS_ENV = "OM_IMPORT_HISTORY_MAX_SINCE_DAYS";
10
+ const IMPORT_HISTORY_MAX_MESSAGES_ENV = "OM_IMPORT_HISTORY_MAX_MESSAGES";
11
+ function readPositiveIntEnv(name, fallback) {
12
+ const raw = typeof process !== "undefined" ? process.env?.[name] : void 0;
13
+ const parsed = Number.parseInt(raw ?? "", 10);
14
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
15
+ }
16
+ function getImportHistoryMaxSinceDays() {
17
+ return Math.max(
18
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
19
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_SINCE_DAYS_ENV, IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK)
20
+ );
21
+ }
22
+ function getImportHistoryMaxMessages() {
23
+ return Math.max(
24
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
25
+ readPositiveIntEnv(IMPORT_HISTORY_MAX_MESSAGES_ENV, IMPORT_HISTORY_MAX_MESSAGES_FALLBACK)
26
+ );
27
+ }
28
+ const PROVIDER_MAX_SINCE_DAYS = {
29
+ imap: 365
30
+ };
31
+ const PROVIDER_MAX_MESSAGES = {
32
+ imap: 5e3
33
+ };
34
+ function getImportHistoryLimits(providerKey) {
35
+ const maxSinceDays = getImportHistoryMaxSinceDays();
36
+ const maxMessages = getImportHistoryMaxMessages();
37
+ const providerMaxSinceDays = providerKey ? PROVIDER_MAX_SINCE_DAYS[providerKey] : void 0;
38
+ const providerMaxMessages = providerKey ? PROVIDER_MAX_MESSAGES[providerKey] : void 0;
39
+ return {
40
+ defaultSinceDays: IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
41
+ defaultMaxMessages: IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
42
+ maxSinceDays: providerMaxSinceDays !== void 0 ? Math.min(maxSinceDays, providerMaxSinceDays) : maxSinceDays,
43
+ maxMessages: providerMaxMessages !== void 0 ? Math.min(maxMessages, providerMaxMessages) : maxMessages
44
+ };
45
+ }
46
+ function resolveImportHistoryPageBudget(maxMessages) {
47
+ if (!Number.isFinite(maxMessages) || maxMessages <= 0) return IMPORT_HISTORY_MIN_PAGE_BUDGET;
48
+ return Math.min(
49
+ IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,
50
+ Math.max(IMPORT_HISTORY_MIN_PAGE_BUDGET, Math.ceil(maxMessages))
51
+ );
52
+ }
53
+ export {
54
+ IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,
55
+ IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,
56
+ IMPORT_HISTORY_DEFAULT_SINCE_DAYS,
57
+ IMPORT_HISTORY_MAX_CONTACT_EMAILS,
58
+ IMPORT_HISTORY_MAX_EMPTY_PAGES,
59
+ IMPORT_HISTORY_MAX_MESSAGES_ENV,
60
+ IMPORT_HISTORY_MAX_MESSAGES_FALLBACK,
61
+ IMPORT_HISTORY_MAX_SINCE_DAYS_ENV,
62
+ IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK,
63
+ IMPORT_HISTORY_MIN_PAGE_BUDGET,
64
+ getImportHistoryLimits,
65
+ getImportHistoryMaxMessages,
66
+ getImportHistoryMaxSinceDays,
67
+ resolveImportHistoryPageBudget
68
+ };
69
+ //# sourceMappingURL=import-history-limits.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/import-history-limits.ts"],
4
+ "sourcesContent": ["/**\n * Single source of truth for the operator-triggered history-import bounds.\n *\n * Defaults are the values a request gets when it omits the field; ceilings are\n * the largest value the hub accepts. Ceilings are deliberately wide enough for\n * a multi-year backfill and can be raised or lowered per deployment through\n * `OM_IMPORT_HISTORY_MAX_SINCE_DAYS` / `OM_IMPORT_HISTORY_MAX_MESSAGES`.\n *\n * Env is read at call time (not module load) so a deployment can change the\n * ceiling without a rebuild, and so tests can exercise the override. In the\n * browser bundle the variables are absent and the built-in ceilings apply \u2014\n * client-side validation is a UX affordance, the API remains the authority.\n */\n\nexport const IMPORT_HISTORY_DEFAULT_SINCE_DAYS = 30\nexport const IMPORT_HISTORY_DEFAULT_MAX_MESSAGES = 1000\n\nexport const IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK = 3650\nexport const IMPORT_HISTORY_MAX_MESSAGES_FALLBACK = 50000\n\nexport const IMPORT_HISTORY_MAX_CONTACT_EMAILS = 200\n\nexport const IMPORT_HISTORY_MIN_PAGE_BUDGET = 100\nexport const IMPORT_HISTORY_ABSOLUTE_MAX_PAGES = 10000\n\n/**\n * Consecutive pages that may return no messages while the adapter still claims\n * `hasMore` and advances its cursor. Empty pages are legitimate: the Gmail\n * adapter drops messages that returned 404/410 between listing and fetching,\n * and server-side filters can eliminate a whole page. A stuck adapter is caught\n * by the repeated-cursor check, so this counter only has to bound the run when\n * the cursor keeps moving but nothing is ever returned.\n */\nexport const IMPORT_HISTORY_MAX_EMPTY_PAGES = 10\n\nexport const IMPORT_HISTORY_MAX_SINCE_DAYS_ENV = 'OM_IMPORT_HISTORY_MAX_SINCE_DAYS'\nexport const IMPORT_HISTORY_MAX_MESSAGES_ENV = 'OM_IMPORT_HISTORY_MAX_MESSAGES'\n\nexport type ImportHistoryLimits = {\n defaultSinceDays: number\n defaultMaxMessages: number\n maxSinceDays: number\n maxMessages: number\n}\n\nfunction readPositiveIntEnv(name: string, fallback: number): number {\n const raw = typeof process !== 'undefined' ? process.env?.[name] : undefined\n const parsed = Number.parseInt(raw ?? '', 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback\n}\n\nexport function getImportHistoryMaxSinceDays(): number {\n return Math.max(\n IMPORT_HISTORY_DEFAULT_SINCE_DAYS,\n readPositiveIntEnv(IMPORT_HISTORY_MAX_SINCE_DAYS_ENV, IMPORT_HISTORY_MAX_SINCE_DAYS_FALLBACK),\n )\n}\n\nexport function getImportHistoryMaxMessages(): number {\n return Math.max(\n IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,\n readPositiveIntEnv(IMPORT_HISTORY_MAX_MESSAGES_ENV, IMPORT_HISTORY_MAX_MESSAGES_FALLBACK),\n )\n}\n\n/**\n * Per-provider ceilings, lower than the deployment-wide ceiling above.\n *\n * The IMAP adapter's cursor embeds the *entire* remaining UID list discovered\n * by `SEARCH` (unlike Gmail's opaque `nextPageToken` cursor), so a wide ceiling\n * there means a multi-megabyte cursor round-tripping through the worker on\n * every page. Until that adapter gets a paging cursor of its own, its ceiling\n * stays at the pre-widening values so the dialog, the hub's validation, and the\n * adapter's own clamp (`packages/channel-imap/.../adapter.ts`) agree \u2014 a\n * request above this is rejected rather than silently truncated.\n */\nconst PROVIDER_MAX_SINCE_DAYS: Record<string, number> = {\n imap: 365,\n}\nconst PROVIDER_MAX_MESSAGES: Record<string, number> = {\n imap: 5000,\n}\n\nexport function getImportHistoryLimits(providerKey?: string): ImportHistoryLimits {\n const maxSinceDays = getImportHistoryMaxSinceDays()\n const maxMessages = getImportHistoryMaxMessages()\n const providerMaxSinceDays = providerKey ? PROVIDER_MAX_SINCE_DAYS[providerKey] : undefined\n const providerMaxMessages = providerKey ? PROVIDER_MAX_MESSAGES[providerKey] : undefined\n return {\n defaultSinceDays: IMPORT_HISTORY_DEFAULT_SINCE_DAYS,\n defaultMaxMessages: IMPORT_HISTORY_DEFAULT_MAX_MESSAGES,\n maxSinceDays: providerMaxSinceDays !== undefined ? Math.min(maxSinceDays, providerMaxSinceDays) : maxSinceDays,\n maxMessages: providerMaxMessages !== undefined ? Math.min(maxMessages, providerMaxMessages) : maxMessages,\n }\n}\n\n/**\n * Page budget for the import drain loop, derived from the effective message cap\n * so a large import is never truncated before it reaches that cap. It is only a\n * backstop: an adapter that never makes progress is stopped much earlier by the\n * consecutive-empty-page guard.\n */\nexport function resolveImportHistoryPageBudget(maxMessages: number): number {\n if (!Number.isFinite(maxMessages) || maxMessages <= 0) return IMPORT_HISTORY_MIN_PAGE_BUDGET\n return Math.min(\n IMPORT_HISTORY_ABSOLUTE_MAX_PAGES,\n Math.max(IMPORT_HISTORY_MIN_PAGE_BUDGET, Math.ceil(maxMessages)),\n )\n}\n"],
5
+ "mappings": "AAcO,MAAM,oCAAoC;AAC1C,MAAM,sCAAsC;AAE5C,MAAM,yCAAyC;AAC/C,MAAM,uCAAuC;AAE7C,MAAM,oCAAoC;AAE1C,MAAM,iCAAiC;AACvC,MAAM,oCAAoC;AAU1C,MAAM,iCAAiC;AAEvC,MAAM,oCAAoC;AAC1C,MAAM,kCAAkC;AAS/C,SAAS,mBAAmB,MAAc,UAA0B;AAClE,QAAM,MAAM,OAAO,YAAY,cAAc,QAAQ,MAAM,IAAI,IAAI;AACnE,QAAM,SAAS,OAAO,SAAS,OAAO,IAAI,EAAE;AAC5C,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEO,SAAS,+BAAuC;AACrD,SAAO,KAAK;AAAA,IACV;AAAA,IACA,mBAAmB,mCAAmC,sCAAsC;AAAA,EAC9F;AACF;AAEO,SAAS,8BAAsC;AACpD,SAAO,KAAK;AAAA,IACV;AAAA,IACA,mBAAmB,iCAAiC,oCAAoC;AAAA,EAC1F;AACF;AAaA,MAAM,0BAAkD;AAAA,EACtD,MAAM;AACR;AACA,MAAM,wBAAgD;AAAA,EACpD,MAAM;AACR;AAEO,SAAS,uBAAuB,aAA2C;AAChF,QAAM,eAAe,6BAA6B;AAClD,QAAM,cAAc,4BAA4B;AAChD,QAAM,uBAAuB,cAAc,wBAAwB,WAAW,IAAI;AAClF,QAAM,sBAAsB,cAAc,sBAAsB,WAAW,IAAI;AAC/E,SAAO;AAAA,IACL,kBAAkB;AAAA,IAClB,oBAAoB;AAAA,IACpB,cAAc,yBAAyB,SAAY,KAAK,IAAI,cAAc,oBAAoB,IAAI;AAAA,IAClG,aAAa,wBAAwB,SAAY,KAAK,IAAI,aAAa,mBAAmB,IAAI;AAAA,EAChG;AACF;AAQO,SAAS,+BAA+B,aAA6B;AAC1E,MAAI,CAAC,OAAO,SAAS,WAAW,KAAK,eAAe,EAAG,QAAO;AAC9D,SAAO,KAAK;AAAA,IACV;AAAA,IACA,KAAK,IAAI,gCAAgC,KAAK,KAAK,WAAW,CAAC;AAAA,EACjE;AACF;",
6
+ "names": []
7
+ }
@@ -6,6 +6,10 @@ import {
6
6
  import { COMMUNICATION_CHANNELS_QUEUES } from "../lib/queue.js";
7
7
  import { refreshCredentialsIfNeeded } from "../lib/credential-refresh.js";
8
8
  import { classifyOutboundError } from "../lib/error-classification.js";
9
+ import {
10
+ IMPORT_HISTORY_MAX_EMPTY_PAGES,
11
+ resolveImportHistoryPageBudget
12
+ } from "../lib/import-history-limits.js";
9
13
  import { createLogger } from "@open-mercato/shared/lib/logger";
10
14
  const logger = createLogger("communication_channels").child({ component: "channel-import-history" });
11
15
  const metadata = {
@@ -101,12 +105,15 @@ async function handle(job, ctx) {
101
105
  let processedCount = 0;
102
106
  let totalCount = maxMessages;
103
107
  let firstPage = true;
104
- const MAX_PAGES = 100;
105
- for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
108
+ const maxPages = resolveImportHistoryPageBudget(maxMessages);
109
+ let consecutiveEmptyPages = 0;
110
+ let truncationReason = null;
111
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
106
112
  if (await progressService.isCancellationRequested(progressJobId, scope.tenantId, scope.organizationId)) {
107
113
  await progressService.markCancelled(progressJobId, progressContext);
108
114
  return;
109
115
  }
116
+ const requestedCursor = cursor;
110
117
  const page = await adapter.importHistory({
111
118
  credentials,
112
119
  scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },
@@ -115,6 +122,19 @@ async function handle(job, ctx) {
115
122
  maxMessages,
116
123
  cursor
117
124
  });
125
+ if (page.hasMore && page.nextCursor !== void 0 && page.nextCursor === requestedCursor) {
126
+ truncationReason = "adapter repeated the same pagination cursor";
127
+ break;
128
+ }
129
+ if (page.messages.length === 0 && page.hasMore) {
130
+ consecutiveEmptyPages += 1;
131
+ if (consecutiveEmptyPages >= IMPORT_HISTORY_MAX_EMPTY_PAGES) {
132
+ truncationReason = `adapter returned no messages on ${consecutiveEmptyPages} consecutive pages`;
133
+ break;
134
+ }
135
+ } else {
136
+ consecutiveEmptyPages = 0;
137
+ }
118
138
  if (firstPage && typeof page.totalCandidates === "number") {
119
139
  totalCount = Math.min(maxMessages, page.totalCandidates);
120
140
  await progressService.updateProgress(
@@ -155,6 +175,20 @@ async function handle(job, ctx) {
155
175
  if (processedCount >= maxMessages) break;
156
176
  if (!page.hasMore || !page.nextCursor) break;
157
177
  cursor = page.nextCursor;
178
+ if (pageIndex === maxPages - 1) {
179
+ truncationReason = `page budget of ${maxPages} exhausted while the adapter still reported more messages`;
180
+ }
181
+ }
182
+ if (truncationReason) {
183
+ logger.warn("history import stopped early", { channelId: channel.id, reason: truncationReason, processedCount });
184
+ await progressService.failJob(
185
+ progressJobId,
186
+ {
187
+ errorMessage: `History import stopped early after ${processedCount} messages: ${truncationReason}`
188
+ },
189
+ progressContext
190
+ );
191
+ return;
158
192
  }
159
193
  await progressService.completeJob(
160
194
  progressJobId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/workers/channel-import-history.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport {\n COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n type IngestInboundMessageInput,\n} from '../commands/ingest-inbound-message'\nimport { COMMUNICATION_CHANNELS_QUEUES } from '../lib/queue'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError } from '../lib/error-classification'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type {\n ChannelImportHistoryJobPayload,\n} from '../commands/queue-import-history'\nimport type {\n ProgressService,\n ProgressServiceContext,\n} from '../../progress/lib/progressService'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'channel-import-history' })\n\n/**\n * Spec B \u00A7 Phase B6 \u2014 operator-triggered backlog import worker.\n *\n * Distinct from `poll-channel` (which runs every scheduler tick and ingests\n * *new* mail since the channel cursor). This worker reaches backward in time\n * by calling `adapter.importHistory` with explicit `sinceDays` / `contactEmails`\n * filters, paginating until the adapter signals `hasMore: false` or the\n * `maxMessages` cap is reached.\n *\n * Runs with `concurrency: 1` to avoid hammering the provider with multiple\n * historical sweeps in parallel; per-channel concurrency is additionally\n * enforced at enqueue time by `queueImportHistory`.\n */\nexport const metadata: WorkerMeta = {\n queue: COMMUNICATION_CHANNELS_QUEUES.importHistory,\n id: 'communication_channels:channel-import-history',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\nexport default async function handle(\n job: QueuedJob<ChannelImportHistoryJobPayload>,\n ctx: HandlerContext,\n): Promise<void> {\n const payload = job.payload\n const { progressJobId, channelId, sinceDays, contactEmails, maxMessages, scope } = payload\n\n const progressService = ctx.resolve<ProgressService>('progressService')\n const progressContext: ProgressServiceContext = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }\n\n const em = (ctx.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.resolve<ChannelAdapterRegistry>('channelAdapterRegistry')\n\n try {\n await progressService.startJob(progressJobId, progressContext)\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!channel) {\n await progressService.failJob(\n progressJobId,\n { errorMessage: 'Channel not found' },\n progressContext,\n )\n return\n }\n if (!channel.isActive || channel.status !== 'connected') {\n await progressService.failJob(\n progressJobId,\n { errorMessage: `Channel is not connected (status=${channel.status})` },\n progressContext,\n )\n return\n }\n\n const adapter = adapterRegistry?.get(channel.providerKey)\n if (!adapter || typeof adapter.importHistory !== 'function') {\n await progressService.failJob(\n progressJobId,\n {\n errorMessage: `Provider \"${channel.providerKey}\" does not support history import`,\n },\n progressContext,\n )\n return\n }\n\n // Resolve + refresh credentials. Same flow as poll-channel.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n const credentialsScope = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n const refreshed = await refreshCredentialsIfNeeded(\n { adapter, channelId: channel.id, credentials, scope: credentialsScope },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n const commandBus = ctx.resolve<CommandBus>('commandBus')\n const containerProxy = { resolve: ctx.resolve.bind(ctx) }\n const commandCtx = {\n container: containerProxy as never,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n\n let cursor: string | undefined\n let processedCount = 0\n let totalCount = maxMessages\n let firstPage = true\n\n // Bounded loop guards against a misbehaving adapter (infinite hasMore).\n // 100 pages * HARD_CAP (default 200) = 20k messages \u2014 far above the 5k cap\n // enforced by the schema, so this only trips on adapter bugs.\n const MAX_PAGES = 100\n for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {\n if (await progressService.isCancellationRequested(progressJobId, scope.tenantId, scope.organizationId)) {\n await progressService.markCancelled(progressJobId, progressContext)\n return\n }\n\n const page = await adapter.importHistory!({\n credentials,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n sinceDays,\n contactEmails,\n maxMessages,\n cursor,\n })\n\n if (firstPage && typeof page.totalCandidates === 'number') {\n totalCount = Math.min(maxMessages, page.totalCandidates)\n await progressService.updateProgress(\n progressJobId,\n { totalCount, processedCount: 0 },\n progressContext,\n )\n firstPage = false\n }\n\n for (const message of page.messages) {\n if (processedCount >= maxMessages) break\n try {\n const input: IngestInboundMessageInput = {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n message,\n }\n await commandBus.execute(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {\n input,\n ctx: commandCtx as never,\n })\n } catch (err) {\n // Import-history is best-effort per-message. Same classification\n // logic as poll-channel: a transient failure aborts the whole job\n // (the operator can retry), permanent failures are logged and\n // skipped (the bad message would loop forever otherwise).\n const classification = classifyOutboundError(err)\n if (classification.transient) {\n throw err\n }\n logger.warn('permanent ingest failure; skipping message', { channelId: channel.id, externalMessageId: message.externalMessageId, reason: classification.message })\n }\n processedCount += 1\n }\n\n await progressService.updateProgress(\n progressJobId,\n { processedCount, totalCount },\n progressContext,\n )\n\n if (processedCount >= maxMessages) break\n if (!page.hasMore || !page.nextCursor) break\n cursor = page.nextCursor\n }\n\n await progressService.completeJob(\n progressJobId,\n { resultSummary: { importedCount: processedCount, channelId } },\n progressContext,\n )\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Import history job failed'\n const stack = err instanceof Error ? err.stack : undefined\n try {\n await progressService.failJob(\n progressJobId,\n { errorMessage: message.slice(0, 2000), errorStack: stack?.slice(0, 10000) },\n progressContext,\n )\n } catch (failErr) {\n logger.error('failed to mark progress job as failed', { progressJobId, err: failErr })\n }\n throw err\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../data/entities'\nimport {\n COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n type IngestInboundMessageInput,\n} from '../commands/ingest-inbound-message'\nimport { COMMUNICATION_CHANNELS_QUEUES } from '../lib/queue'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError } from '../lib/error-classification'\nimport {\n IMPORT_HISTORY_MAX_EMPTY_PAGES,\n resolveImportHistoryPageBudget,\n} from '../lib/import-history-limits'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type {\n ChannelImportHistoryJobPayload,\n} from '../commands/queue-import-history'\nimport type {\n ProgressService,\n ProgressServiceContext,\n} from '../../progress/lib/progressService'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'channel-import-history' })\n\n/**\n * Spec B \u00A7 Phase B6 \u2014 operator-triggered backlog import worker.\n *\n * Distinct from `poll-channel` (which runs every scheduler tick and ingests\n * *new* mail since the channel cursor). This worker reaches backward in time\n * by calling `adapter.importHistory` with explicit `sinceDays` / `contactEmails`\n * filters, paginating until the adapter signals `hasMore: false` or the\n * `maxMessages` cap is reached.\n *\n * Runs with `concurrency: 1` to avoid hammering the provider with multiple\n * historical sweeps in parallel; per-channel concurrency is additionally\n * enforced at enqueue time by `queueImportHistory`.\n */\nexport const metadata: WorkerMeta = {\n queue: COMMUNICATION_CHANNELS_QUEUES.importHistory,\n id: 'communication_channels:channel-import-history',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\nexport default async function handle(\n job: QueuedJob<ChannelImportHistoryJobPayload>,\n ctx: HandlerContext,\n): Promise<void> {\n const payload = job.payload\n const { progressJobId, channelId, sinceDays, contactEmails, maxMessages, scope } = payload\n\n const progressService = ctx.resolve<ProgressService>('progressService')\n const progressContext: ProgressServiceContext = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }\n\n const em = (ctx.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.resolve<ChannelAdapterRegistry>('channelAdapterRegistry')\n\n try {\n await progressService.startJob(progressJobId, progressContext)\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!channel) {\n await progressService.failJob(\n progressJobId,\n { errorMessage: 'Channel not found' },\n progressContext,\n )\n return\n }\n if (!channel.isActive || channel.status !== 'connected') {\n await progressService.failJob(\n progressJobId,\n { errorMessage: `Channel is not connected (status=${channel.status})` },\n progressContext,\n )\n return\n }\n\n const adapter = adapterRegistry?.get(channel.providerKey)\n if (!adapter || typeof adapter.importHistory !== 'function') {\n await progressService.failJob(\n progressJobId,\n {\n errorMessage: `Provider \"${channel.providerKey}\" does not support history import`,\n },\n progressContext,\n )\n return\n }\n\n // Resolve + refresh credentials. Same flow as poll-channel.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n const credentialsScope = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n const refreshed = await refreshCredentialsIfNeeded(\n { adapter, channelId: channel.id, credentials, scope: credentialsScope },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n const commandBus = ctx.resolve<CommandBus>('commandBus')\n const containerProxy = { resolve: ctx.resolve.bind(ctx) }\n const commandCtx = {\n container: containerProxy as never,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n\n let cursor: string | undefined\n let processedCount = 0\n let totalCount = maxMessages\n let firstPage = true\n\n // Guards against an adapter that claims `hasMore` forever. An empty page is\n // NOT a fault on its own \u2014 adapters legitimately drop every message on a\n // page (Gmail 404/410 between listing and fetching, server-side filters) \u2014\n // so the hard stop is a cursor that stops advancing; the empty-page counter\n // and the page budget are backstops.\n const maxPages = resolveImportHistoryPageBudget(maxMessages)\n let consecutiveEmptyPages = 0\n let truncationReason: string | null = null\n for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {\n if (await progressService.isCancellationRequested(progressJobId, scope.tenantId, scope.organizationId)) {\n await progressService.markCancelled(progressJobId, progressContext)\n return\n }\n\n const requestedCursor = cursor\n const page = await adapter.importHistory!({\n credentials,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n sinceDays,\n contactEmails,\n maxMessages,\n cursor,\n })\n\n if (page.hasMore && page.nextCursor !== undefined && page.nextCursor === requestedCursor) {\n truncationReason = 'adapter repeated the same pagination cursor'\n break\n }\n\n if (page.messages.length === 0 && page.hasMore) {\n consecutiveEmptyPages += 1\n if (consecutiveEmptyPages >= IMPORT_HISTORY_MAX_EMPTY_PAGES) {\n truncationReason = `adapter returned no messages on ${consecutiveEmptyPages} consecutive pages`\n break\n }\n } else {\n consecutiveEmptyPages = 0\n }\n\n if (firstPage && typeof page.totalCandidates === 'number') {\n totalCount = Math.min(maxMessages, page.totalCandidates)\n await progressService.updateProgress(\n progressJobId,\n { totalCount, processedCount: 0 },\n progressContext,\n )\n firstPage = false\n }\n\n for (const message of page.messages) {\n if (processedCount >= maxMessages) break\n try {\n const input: IngestInboundMessageInput = {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n scope: { tenantId: scope.tenantId, organizationId: scope.organizationId },\n message,\n }\n await commandBus.execute(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {\n input,\n ctx: commandCtx as never,\n })\n } catch (err) {\n // Import-history is best-effort per-message. Same classification\n // logic as poll-channel: a transient failure aborts the whole job\n // (the operator can retry), permanent failures are logged and\n // skipped (the bad message would loop forever otherwise).\n const classification = classifyOutboundError(err)\n if (classification.transient) {\n throw err\n }\n logger.warn('permanent ingest failure; skipping message', { channelId: channel.id, externalMessageId: message.externalMessageId, reason: classification.message })\n }\n processedCount += 1\n }\n\n await progressService.updateProgress(\n progressJobId,\n { processedCount, totalCount },\n progressContext,\n )\n\n if (processedCount >= maxMessages) break\n if (!page.hasMore || !page.nextCursor) break\n cursor = page.nextCursor\n if (pageIndex === maxPages - 1) {\n truncationReason = `page budget of ${maxPages} exhausted while the adapter still reported more messages`\n }\n }\n\n if (truncationReason) {\n // Surfaced as a failed job on purpose: the operator must see that the\n // backlog is incomplete rather than a clean \"imported N\" summary.\n logger.warn('history import stopped early', { channelId: channel.id, reason: truncationReason, processedCount })\n await progressService.failJob(\n progressJobId,\n {\n errorMessage: `History import stopped early after ${processedCount} messages: ${truncationReason}`,\n },\n progressContext,\n )\n return\n }\n\n await progressService.completeJob(\n progressJobId,\n { resultSummary: { importedCount: processedCount, channelId } },\n progressContext,\n )\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Import history job failed'\n const stack = err instanceof Error ? err.stack : undefined\n try {\n await progressService.failJob(\n progressJobId,\n { errorMessage: message.slice(0, 2000), errorStack: stack?.slice(0, 10000) },\n progressContext,\n )\n } catch (failErr) {\n logger.error('failed to mark progress job as failed', { progressJobId, err: failErr })\n }\n throw err\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,6 +28,7 @@ import { resolveListCountCap } from "@open-mercato/shared/lib/query/count-cap";
28
28
  import { mapWithConcurrency } from "@open-mercato/shared/lib/query/bounded-decrypt";
29
29
  import { createLogger } from "@open-mercato/shared/lib/logger";
30
30
  import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
31
+ import { createBoundedTtlMemo } from "@open-mercato/shared/lib/query/bounded-ttl-memo";
31
32
  const logger = createLogger("query_index").child({ component: "engine" });
32
33
  const CF_FILTER_SUPPORTED_OPS = /* @__PURE__ */ new Set([
33
34
  "eq",
@@ -70,6 +71,18 @@ function markAutoReindexScheduled(key, debounceMs, now) {
70
71
  autoReindexScheduledAt.set(key, now);
71
72
  return true;
72
73
  }
74
+ const baseTableExistsCache = createBoundedTtlMemo({
75
+ ttlEnv: "OM_QUERY_INDEX_BASE_TABLE_EXISTS_CACHE_MS",
76
+ maxEntriesEnv: "OM_QUERY_INDEX_BASE_TABLE_EXISTS_CACHE_MAX_ENTRIES",
77
+ defaultTtlMs: 36e5,
78
+ defaultMaxEntries: 1e3
79
+ });
80
+ function clearBaseTableExistsCache() {
81
+ baseTableExistsCache.clear();
82
+ }
83
+ function baseTableExistsCacheSize() {
84
+ return baseTableExistsCache.size();
85
+ }
73
86
  function buildFilterableCustomFieldJoins(sources) {
74
87
  if (!sources || sources.length === 0) return [];
75
88
  return sources.flatMap((source, index) => {
@@ -1718,9 +1731,12 @@ class HybridQueryEngine {
1718
1731
  return result;
1719
1732
  }
1720
1733
  async tableExists(table) {
1734
+ if (baseTableExistsCache.get(table)) return true;
1721
1735
  const db = this.getDb();
1722
1736
  const exists = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", table).executeTakeFirst();
1723
- return !!exists;
1737
+ const present = !!exists;
1738
+ if (present) baseTableExistsCache.set(table, true);
1739
+ return present;
1724
1740
  }
1725
1741
  async resolveAvailableCustomFieldKeys(entityIds, tenantId) {
1726
1742
  if (!entityIds.length) return [];
@@ -2213,6 +2229,8 @@ class HybridQueryEngine {
2213
2229
  }
2214
2230
  export {
2215
2231
  HybridQueryEngine,
2232
+ baseTableExistsCacheSize,
2233
+ clearBaseTableExistsCache,
2216
2234
  coerceSortDirection
2217
2235
  };
2218
2236
  //# sourceMappingURL=engine.js.map