@open-mercato/core 0.6.8-develop.6889.1.af4501ca97 → 0.6.8-develop.6892.1.f179677788

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/api/get/me/channels/route.js +16 -2
  3. package/dist/modules/communication_channels/api/get/me/channels/route.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/poll-now/route.js +14 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/poll-now/route.js.map +2 -2
  6. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +24 -11
  7. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  8. package/dist/modules/communication_channels/lib/polling-eligibility.js +8 -0
  9. package/dist/modules/communication_channels/lib/polling-eligibility.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/poll-channel.js +2 -2
  11. package/dist/modules/communication_channels/workers/poll-channel.js.map +2 -2
  12. package/dist/modules/configs/cli.js +12 -10
  13. package/dist/modules/configs/cli.js.map +2 -2
  14. package/dist/modules/configs/lib/touchGeneratedBarrels.js +2 -2
  15. package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +2 -2
  16. package/dist/modules/customers/message-objects.js +1 -1
  17. package/dist/modules/customers/message-objects.js.map +1 -1
  18. package/package.json +7 -7
  19. package/src/modules/communication_channels/api/get/me/channels/route.ts +24 -2
  20. package/src/modules/communication_channels/api/post/channels/[id]/poll-now/route.ts +22 -1
  21. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +58 -13
  22. package/src/modules/communication_channels/i18n/de.json +6 -0
  23. package/src/modules/communication_channels/i18n/en.json +6 -0
  24. package/src/modules/communication_channels/i18n/es.json +6 -0
  25. package/src/modules/communication_channels/i18n/ko.json +6 -0
  26. package/src/modules/communication_channels/i18n/pl.json +6 -0
  27. package/src/modules/communication_channels/lib/polling-eligibility.ts +14 -0
  28. package/src/modules/communication_channels/workers/poll-channel.ts +3 -3
  29. package/src/modules/configs/cli.ts +12 -10
  30. package/src/modules/configs/lib/touchGeneratedBarrels.ts +5 -2
  31. package/src/modules/customers/message-objects.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/workers/poll-channel.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, getCommunicationChannelsQueue } from '../lib/queue'\nimport { preservePushState } from '../lib/push-state'\nimport { writeIngestDeadLetter } from '../lib/dead-letter'\nimport { classifyOutboundError, computeBackoffMs, isReauthError } from '../lib/error-classification'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type { NormalizedInboundMessage } from '../lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'poll-channel' })\n\n/**\n * Job payload for the `communication-channels-poll` queue.\n *\n * One job per channel per scheduler tick. The poll-tick worker (sibling file)\n * enumerates due channels and enqueues these jobs.\n */\nexport type PollChannelJobPayload = {\n channelId: string\n scope: {\n tenantId: string\n organizationId: string | null\n }\n /** Attempt count, 1-based; used for retry-backoff decisions. */\n attempt?: number\n /** Self-re-enqueue drain counter (bounds the multi-page `hasMore` drain loop). */\n drainPage?: number\n}\n\nexport const POLL_CHANNEL_MAX_ATTEMPTS = 3\n\n/**\n * Hard cap on `hasMore` self-re-enqueue drain pages \u2014 guards against an adapter\n * that returns `hasMore: true` with a non-advancing (pinned) cursor, which would\n * otherwise spin a tight, unthrottled re-enqueue loop. Mirrors the same guard in\n * `gmail-history-sync`.\n */\nconst MAX_DRAIN_PAGES = 100\n\nexport const metadata: WorkerMeta = {\n queue: COMMUNICATION_CHANNELS_QUEUES.poll,\n id: 'communication_channels:poll-channel',\n concurrency: 10,\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\n/**\n * Poll a single channel for inbound messages.\n *\n * Per SPEC-045d \u00A7 6 (with email-spec \u00A7 Hub Deltas \u2192 Delta 6 polling extensions):\n * 1. Load the channel; skip when `is_active === false` or `status !== 'connected'`.\n * 2. Skip when the adapter declares `realtimePush !== false` (it doesn't want polling).\n * 3. Refresh credentials if OAuth and within the expiry window.\n * 4. Call `adapter.fetchHistory({ channelId, credentials, since: lastPolledAt })`.\n * 5. For each normalized message, dispatch `ingest_inbound_message` (idempotent on\n * `(channel_id, external_message_id)`).\n * 6. Update `channel.lastPolledAt = NOW()` on success.\n * 7. On error: classify, set `channel.status` accordingly, set `last_error`, emit\n * `channel.requires_reauth` on 401, retry transient failures up to MAX.\n *\n * The hub doesn't drain a remote mailbox indefinitely \u2014 `fetchHistory` returns a\n * single page (provider decides the size). If the provider has more, the next tick\n * picks it up after the configured `poll_interval_seconds`.\n */\nexport default async function handle(\n job: QueuedJob<PollChannelJobPayload>,\n ctx: HandlerContext,\n): Promise<void> {\n const { channelId, scope, attempt = 1, drainPage = 0 } = job.payload\n const em = (ctx.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.resolve<ChannelAdapterRegistry>('channelAdapterRegistry')\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!channel) {\n logger.warn('channel not found (skipping)', { channelId })\n return\n }\n if (!channel.isActive) return\n // Allow `connected` (normal poll) and `error` (Spec B \u00A7 B5 auto-recovery\n // sweep enqueues these intentionally \u2014 on a successful poll below we\n // flip them back to `connected`). `requires_reauth` and `disconnected`\n // are owned by the credential-refresh and disconnect flows.\n if (channel.status !== 'connected' && channel.status !== 'error') return\n\n const adapter = adapterRegistry?.get(channel.providerKey)\n if (!adapter) {\n logger.warn('no adapter for provider', { providerKey: channel.providerKey, channelId })\n return\n }\n // Adapter opted out of polling \u2014 webhook providers.\n const capabilities = (channel.capabilities as { realtimePush?: boolean } | null) ?? null\n if (capabilities?.realtimePush !== false) {\n // realtimePush is `true` (default for back-compat) \u2014 don't poll push providers.\n return\n }\n if (typeof adapter.fetchHistory !== 'function') {\n // Adapter doesn't implement history fetching \u2014 nothing we can do.\n return\n }\n\n // Credentials.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected the provider last.\n // See review R2-C1 / N1 (2026-05-26).\n const credentialsScope = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? scope.tenantId,\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 {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n // Fetch a single page of history.\n // `channelState` is the provider-specific resumption cursor \u2014 Gmail historyId,\n // IMAP UIDVALIDITY+UIDNEXT, etc. We persist it across\n // ticks on `channel.channelState` so each poll resumes from the prior one\n // instead of running a full mailbox resync. Empty / NULL = \"first poll;\n // bootstrap the cursor from the provider\".\n let normalized: NormalizedInboundMessage[] = []\n let nextCursor: string | undefined\n let hasMore = false\n try {\n const result = await adapter.fetchHistory({\n conversationId: channel.externalIdentifier ?? channel.id,\n credentials,\n cursor: channel.lastPolledAt ? channel.lastPolledAt.toISOString() : undefined,\n channelState: (channel.channelState as Record<string, unknown> | null) ?? undefined,\n scope: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? scope.tenantId,\n },\n // `contactFilter.sinceDays` is a hint to provider adapters about how far\n // back to look on first-poll bootstrap. For UID-incremental polls (every\n // tick after the first), adapters ignore this and just fetch new mail\n // since the persisted cursor.\n contactFilter: { addresses: [], sinceDays: 7 },\n })\n normalized = Array.isArray(result?.messages) ? result.messages : []\n nextCursor = result?.nextCursor\n hasMore = result?.hasMore === true\n } catch (err) {\n await handlePollError(err, em, channel, scope, attempt, job.payload)\n return\n }\n\n // Dispatch ingest commands for each message.\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 ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n // Spec B \u00A7 Per-message commit + dead-letter:\n // - Permanent failure (malformed MIME, schema, contract violation):\n // write to channel_ingest_dead_letter, log, advance cursor anyway so\n // the bad blob never stalls the channel again.\n // - Transient failure (DB drop, network blip): abort the loop without\n // advancing the cursor. The next tick re-fetches the same page;\n // idempotency via the (channel_id, external_message_id) unique\n // constraint means already-ingested messages no-op on the retry.\n let transientIngestAbort = false\n for (const message of normalized) {\n try {\n const input: IngestInboundMessageInput = {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n scope: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n },\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 const classification = classifyOutboundError(err)\n if (classification.transient) {\n logger.warn('transient ingest failure; aborting page so cursor is not advanced', { channelId: channel.id, reason: classification.message })\n transientIngestAbort = true\n break\n }\n // Permanent \u2014 write to dead-letter so an operator can replay later.\n // The shared helper is best-effort (never throws) and idempotent on\n // `(channelId, externalMessageId)`, so a replayed page that fails the\n // same message again does not insert a duplicate row.\n await writeIngestDeadLetter({\n em,\n scope,\n channel,\n message,\n err,\n errorMessage: classification.message,\n })\n logger.warn('permanent ingest failure; recorded in dead-letter and advancing cursor', { channelId: channel.id, externalMessageId: message.externalMessageId, reason: classification.message })\n }\n }\n\n // Transient abort: keep prior cursor + lastPolledAt so the next tick\n // re-fetches the same page (idempotent at the DB layer).\n if (transientIngestAbort) {\n channel.lastError = 'transient_ingest_failure'\n await em.flush()\n return\n }\n\n // Update poll cursor + clear any stale error state.\n channel.lastPolledAt = new Date()\n if (channel.lastError) channel.lastError = null\n // Recover the channel from any prior non-fatal error state. The previous\n // poll(s) may have set status='error' after exhausting transient-retry\n // attempts, but a fresh successful poll means the upstream is healthy\n // again \u2014 flip it back to 'connected' so the scheduler keeps it in\n // rotation and the user doesn't have to manually reconnect.\n // We DON'T touch 'requires_reauth' here (that lifecycle state is owned\n // by the credential-refresh / OAuth flow) or 'disconnected' (owned by\n // the cascade-on-user-delete subscriber).\n if (channel.status === 'error') {\n channel.status = 'connected'\n }\n // Providers encode their cursor as base64-encoded JSON in `nextCursor`; we\n // decode it back to an object so the next tick can pass it straight into\n // `fetchHistory` as `channelState`. Decode failures fall back to the prior\n // state (next tick bootstraps from there).\n //\n // Push-delivery state (Spec C) \u2014 watch/subscription identifiers and expiry \u2014 is\n // owned by the push register/renew commands, not the sync cursor. A provider's\n // `fetchHistory` returns only sync-cursor fields, so persisting the decoded\n // cursor as a full replace would silently wipe push state and stop\n // `gmail-renew-watch` from renewing it. Carry\n // the hub-owned push keys forward whenever the new cursor omits them.\n if (typeof nextCursor === 'string' && nextCursor.length > 0) {\n const decoded = decodeChannelStateCursor(nextCursor)\n if (decoded) {\n channel.channelState = preservePushState(channel.channelState, decoded)\n }\n }\n await em.flush()\n\n // Drain contract (review H1, 2026-05-26): adapters that have additional\n // pages beyond this tick's batch (large mailboxes, mid-deltaLink walks,\n // UID overflows) signal `hasMore: true`. The persisted `channelState`\n // already encodes the mid-drain resumption token (Gmail `pendingHistoryPageToken`,\n // IMAP non-terminal `uidNext`). Re-enqueue with\n // a small delay so we keep draining without overrunning rate limits.\n if (hasMore) {\n // Bound the drain: an adapter that returns `hasMore: true` with a\n // non-advancing cursor (e.g. a persistently-failing message that pins the\n // Gmail cursor via `hardFailed`) must not spin an unthrottled loop. Stop\n // at MAX_DRAIN_PAGES; the next scheduled poll tick re-checks the channel.\n if (drainPage < MAX_DRAIN_PAGES) {\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.poll)\n await queue.enqueue(\n { channelId: channel.id, scope, attempt: 1, drainPage: drainPage + 1 } as unknown as Record<string, unknown>,\n { delayMs: 250 },\n )\n } else {\n logger.warn('drain page cap reached; stopping re-enqueue until the next scheduled tick', { maxDrainPages: MAX_DRAIN_PAGES, channelId: channel.id })\n }\n }\n}\n\nfunction decodeChannelStateCursor(cursor: string): Record<string, unknown> | null {\n try {\n const decoded = Buffer.from(cursor, 'base64').toString('utf8')\n const parsed = JSON.parse(decoded) as unknown\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>\n }\n return null\n } catch {\n return null\n }\n}\n\nasync function handlePollError(\n err: unknown,\n em: EntityManager,\n channel: CommunicationChannel,\n scope: PollChannelJobPayload['scope'],\n attempt: number,\n payload: PollChannelJobPayload,\n): Promise<void> {\n const classification = classifyOutboundError(err)\n channel.lastError = classification.message\n\n if (isReauthError(classification)) {\n channel.status = 'requires_reauth'\n await em.flush()\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n },\n { persistent: true },\n )\n return\n }\n\n if (classification.transient && attempt < POLL_CHANNEL_MAX_ATTEMPTS) {\n // Transient error \u2014 re-enqueue with backoff. Channel status stays\n // `connected` so the scheduler keeps it in rotation.\n await em.flush()\n const next: PollChannelJobPayload = { ...payload, attempt: attempt + 1 }\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.poll)\n await queue.enqueue(next as unknown as Record<string, unknown>, {\n delayMs: computeBackoffMs(attempt),\n })\n return\n }\n\n // Permanent or attempts exhausted \u2014 mark channel as error and stop the loop.\n channel.status = 'error'\n await em.flush()\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, getCommunicationChannelsQueue } from '../lib/queue'\nimport { preservePushState } from '../lib/push-state'\nimport { isHubPolledChannel } from '../lib/polling-eligibility'\nimport { writeIngestDeadLetter } from '../lib/dead-letter'\nimport { classifyOutboundError, computeBackoffMs, isReauthError } from '../lib/error-classification'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type { NormalizedInboundMessage } from '../lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'poll-channel' })\n\n/**\n * Job payload for the `communication-channels-poll` queue.\n *\n * One job per channel per scheduler tick. The poll-tick worker (sibling file)\n * enumerates due channels and enqueues these jobs.\n */\nexport type PollChannelJobPayload = {\n channelId: string\n scope: {\n tenantId: string\n organizationId: string | null\n }\n /** Attempt count, 1-based; used for retry-backoff decisions. */\n attempt?: number\n /** Self-re-enqueue drain counter (bounds the multi-page `hasMore` drain loop). */\n drainPage?: number\n}\n\nexport const POLL_CHANNEL_MAX_ATTEMPTS = 3\n\n/**\n * Hard cap on `hasMore` self-re-enqueue drain pages \u2014 guards against an adapter\n * that returns `hasMore: true` with a non-advancing (pinned) cursor, which would\n * otherwise spin a tight, unthrottled re-enqueue loop. Mirrors the same guard in\n * `gmail-history-sync`.\n */\nconst MAX_DRAIN_PAGES = 100\n\nexport const metadata: WorkerMeta = {\n queue: COMMUNICATION_CHANNELS_QUEUES.poll,\n id: 'communication_channels:poll-channel',\n concurrency: 10,\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\n/**\n * Poll a single channel for inbound messages.\n *\n * Per SPEC-045d \u00A7 6 (with email-spec \u00A7 Hub Deltas \u2192 Delta 6 polling extensions):\n * 1. Load the channel; skip when `is_active === false` or `status !== 'connected'`.\n * 2. Skip when the adapter declares `realtimePush !== false` (it doesn't want polling).\n * 3. Refresh credentials if OAuth and within the expiry window.\n * 4. Call `adapter.fetchHistory({ channelId, credentials, since: lastPolledAt })`.\n * 5. For each normalized message, dispatch `ingest_inbound_message` (idempotent on\n * `(channel_id, external_message_id)`).\n * 6. Update `channel.lastPolledAt = NOW()` on success.\n * 7. On error: classify, set `channel.status` accordingly, set `last_error`, emit\n * `channel.requires_reauth` on 401, retry transient failures up to MAX.\n *\n * The hub doesn't drain a remote mailbox indefinitely \u2014 `fetchHistory` returns a\n * single page (provider decides the size). If the provider has more, the next tick\n * picks it up after the configured `poll_interval_seconds`.\n */\nexport default async function handle(\n job: QueuedJob<PollChannelJobPayload>,\n ctx: HandlerContext,\n): Promise<void> {\n const { channelId, scope, attempt = 1, drainPage = 0 } = job.payload\n const em = (ctx.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.resolve<ChannelAdapterRegistry>('channelAdapterRegistry')\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: channelId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!channel) {\n logger.warn('channel not found (skipping)', { channelId })\n return\n }\n if (!channel.isActive) return\n // Allow `connected` (normal poll) and `error` (Spec B \u00A7 B5 auto-recovery\n // sweep enqueues these intentionally \u2014 on a successful poll below we\n // flip them back to `connected`). `requires_reauth` and `disconnected`\n // are owned by the credential-refresh and disconnect flows.\n if (channel.status !== 'connected' && channel.status !== 'error') return\n\n const adapter = adapterRegistry?.get(channel.providerKey)\n if (!adapter) {\n logger.warn('no adapter for provider', { providerKey: channel.providerKey, channelId })\n return\n }\n // Adapter opted out of polling \u2014 webhook and gateway providers.\n if (!isHubPolledChannel(channel.capabilities)) {\n // realtimePush is `true` (default for back-compat) \u2014 don't poll push providers.\n return\n }\n if (typeof adapter.fetchHistory !== 'function') {\n // Adapter doesn't implement history fetching \u2014 nothing we can do.\n return\n }\n\n // Credentials.\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected the provider last.\n // See review R2-C1 / N1 (2026-05-26).\n const credentialsScope = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? scope.tenantId,\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 {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n // Fetch a single page of history.\n // `channelState` is the provider-specific resumption cursor \u2014 Gmail historyId,\n // IMAP UIDVALIDITY+UIDNEXT, etc. We persist it across\n // ticks on `channel.channelState` so each poll resumes from the prior one\n // instead of running a full mailbox resync. Empty / NULL = \"first poll;\n // bootstrap the cursor from the provider\".\n let normalized: NormalizedInboundMessage[] = []\n let nextCursor: string | undefined\n let hasMore = false\n try {\n const result = await adapter.fetchHistory({\n conversationId: channel.externalIdentifier ?? channel.id,\n credentials,\n cursor: channel.lastPolledAt ? channel.lastPolledAt.toISOString() : undefined,\n channelState: (channel.channelState as Record<string, unknown> | null) ?? undefined,\n scope: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? scope.tenantId,\n },\n // `contactFilter.sinceDays` is a hint to provider adapters about how far\n // back to look on first-poll bootstrap. For UID-incremental polls (every\n // tick after the first), adapters ignore this and just fetch new mail\n // since the persisted cursor.\n contactFilter: { addresses: [], sinceDays: 7 },\n })\n normalized = Array.isArray(result?.messages) ? result.messages : []\n nextCursor = result?.nextCursor\n hasMore = result?.hasMore === true\n } catch (err) {\n await handlePollError(err, em, channel, scope, attempt, job.payload)\n return\n }\n\n // Dispatch ingest commands for each message.\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 ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n // Spec B \u00A7 Per-message commit + dead-letter:\n // - Permanent failure (malformed MIME, schema, contract violation):\n // write to channel_ingest_dead_letter, log, advance cursor anyway so\n // the bad blob never stalls the channel again.\n // - Transient failure (DB drop, network blip): abort the loop without\n // advancing the cursor. The next tick re-fetches the same page;\n // idempotency via the (channel_id, external_message_id) unique\n // constraint means already-ingested messages no-op on the retry.\n let transientIngestAbort = false\n for (const message of normalized) {\n try {\n const input: IngestInboundMessageInput = {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n scope: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n },\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 const classification = classifyOutboundError(err)\n if (classification.transient) {\n logger.warn('transient ingest failure; aborting page so cursor is not advanced', { channelId: channel.id, reason: classification.message })\n transientIngestAbort = true\n break\n }\n // Permanent \u2014 write to dead-letter so an operator can replay later.\n // The shared helper is best-effort (never throws) and idempotent on\n // `(channelId, externalMessageId)`, so a replayed page that fails the\n // same message again does not insert a duplicate row.\n await writeIngestDeadLetter({\n em,\n scope,\n channel,\n message,\n err,\n errorMessage: classification.message,\n })\n logger.warn('permanent ingest failure; recorded in dead-letter and advancing cursor', { channelId: channel.id, externalMessageId: message.externalMessageId, reason: classification.message })\n }\n }\n\n // Transient abort: keep prior cursor + lastPolledAt so the next tick\n // re-fetches the same page (idempotent at the DB layer).\n if (transientIngestAbort) {\n channel.lastError = 'transient_ingest_failure'\n await em.flush()\n return\n }\n\n // Update poll cursor + clear any stale error state.\n channel.lastPolledAt = new Date()\n if (channel.lastError) channel.lastError = null\n // Recover the channel from any prior non-fatal error state. The previous\n // poll(s) may have set status='error' after exhausting transient-retry\n // attempts, but a fresh successful poll means the upstream is healthy\n // again \u2014 flip it back to 'connected' so the scheduler keeps it in\n // rotation and the user doesn't have to manually reconnect.\n // We DON'T touch 'requires_reauth' here (that lifecycle state is owned\n // by the credential-refresh / OAuth flow) or 'disconnected' (owned by\n // the cascade-on-user-delete subscriber).\n if (channel.status === 'error') {\n channel.status = 'connected'\n }\n // Providers encode their cursor as base64-encoded JSON in `nextCursor`; we\n // decode it back to an object so the next tick can pass it straight into\n // `fetchHistory` as `channelState`. Decode failures fall back to the prior\n // state (next tick bootstraps from there).\n //\n // Push-delivery state (Spec C) \u2014 watch/subscription identifiers and expiry \u2014 is\n // owned by the push register/renew commands, not the sync cursor. A provider's\n // `fetchHistory` returns only sync-cursor fields, so persisting the decoded\n // cursor as a full replace would silently wipe push state and stop\n // `gmail-renew-watch` from renewing it. Carry\n // the hub-owned push keys forward whenever the new cursor omits them.\n if (typeof nextCursor === 'string' && nextCursor.length > 0) {\n const decoded = decodeChannelStateCursor(nextCursor)\n if (decoded) {\n channel.channelState = preservePushState(channel.channelState, decoded)\n }\n }\n await em.flush()\n\n // Drain contract (review H1, 2026-05-26): adapters that have additional\n // pages beyond this tick's batch (large mailboxes, mid-deltaLink walks,\n // UID overflows) signal `hasMore: true`. The persisted `channelState`\n // already encodes the mid-drain resumption token (Gmail `pendingHistoryPageToken`,\n // IMAP non-terminal `uidNext`). Re-enqueue with\n // a small delay so we keep draining without overrunning rate limits.\n if (hasMore) {\n // Bound the drain: an adapter that returns `hasMore: true` with a\n // non-advancing cursor (e.g. a persistently-failing message that pins the\n // Gmail cursor via `hardFailed`) must not spin an unthrottled loop. Stop\n // at MAX_DRAIN_PAGES; the next scheduled poll tick re-checks the channel.\n if (drainPage < MAX_DRAIN_PAGES) {\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.poll)\n await queue.enqueue(\n { channelId: channel.id, scope, attempt: 1, drainPage: drainPage + 1 } as unknown as Record<string, unknown>,\n { delayMs: 250 },\n )\n } else {\n logger.warn('drain page cap reached; stopping re-enqueue until the next scheduled tick', { maxDrainPages: MAX_DRAIN_PAGES, channelId: channel.id })\n }\n }\n}\n\nfunction decodeChannelStateCursor(cursor: string): Record<string, unknown> | null {\n try {\n const decoded = Buffer.from(cursor, 'base64').toString('utf8')\n const parsed = JSON.parse(decoded) as unknown\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>\n }\n return null\n } catch {\n return null\n }\n}\n\nasync function handlePollError(\n err: unknown,\n em: EntityManager,\n channel: CommunicationChannel,\n scope: PollChannelJobPayload['scope'],\n attempt: number,\n payload: PollChannelJobPayload,\n): Promise<void> {\n const classification = classifyOutboundError(err)\n channel.lastError = classification.message\n\n if (isReauthError(classification)) {\n channel.status = 'requires_reauth'\n await em.flush()\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId ?? null,\n },\n { persistent: true },\n )\n return\n }\n\n if (classification.transient && attempt < POLL_CHANNEL_MAX_ATTEMPTS) {\n // Transient error \u2014 re-enqueue with backoff. Channel status stays\n // `connected` so the scheduler keeps it in rotation.\n await em.flush()\n const next: PollChannelJobPayload = { ...payload, attempt: attempt + 1 }\n const queue = getCommunicationChannelsQueue(COMMUNICATION_CHANNELS_QUEUES.poll)\n await queue.enqueue(next as unknown as Record<string, unknown>, {\n delayMs: computeBackoffMs(attempt),\n })\n return\n }\n\n // Permanent or attempts exhausted \u2014 mark channel as error and stop the loop.\n channel.status = 'error'\n await em.flush()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -125,12 +125,12 @@ function printCacheHelp() {
125
125
  console.log(" yarn mercato configs cache purge --key <key1,key2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]");
126
126
  console.log(" yarn mercato configs cache purge --id <token1,token2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]");
127
127
  console.log(" yarn mercato configs cache purge --pattern <glob> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]");
128
- console.log(" yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]");
128
+ console.log(" yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--touch-generated] [--dry-run] [--json]");
129
129
  console.log("");
130
130
  console.log("\u2139\uFE0F Notes:");
131
131
  console.log(" `stats` mirrors the cache admin page segment overview for CRUD/widget caches.");
132
132
  console.log(" `purge --id` removes every key whose name contains the provided token (for example a user id or entity id).");
133
- console.log(" `structural` targets navigation/sidebar caches and is the recommended post-step after module/sidebar structure changes.");
133
+ console.log(" `structural` targets navigation/sidebar caches. Add `--touch-generated` only for explicit stale-compiler recovery.");
134
134
  console.log(" When no scope flag is supplied, this command uses the global cache scope only.");
135
135
  }
136
136
  async function disposeContainer(container) {
@@ -228,14 +228,16 @@ async function runStructuralCachePurge(args) {
228
228
  if (json) {
229
229
  console.log(JSON.stringify(structuralResults, null, 2));
230
230
  }
231
- const quiet = flagEnabled(args, "quiet");
232
- try {
233
- touchGeneratedBarrels({ quiet: quiet || json });
234
- } catch (err) {
235
- if (!quiet && !json) {
236
- console.warn(
237
- `[structural] failed to touch generated barrels: ${err.message ?? err}`
238
- );
231
+ if (flagEnabled(args, "touch-generated", "touchGenerated")) {
232
+ const quiet = flagEnabled(args, "quiet");
233
+ try {
234
+ touchGeneratedBarrels({ quiet: quiet || json });
235
+ } catch (err) {
236
+ if (!quiet && !json) {
237
+ console.warn(
238
+ `[structural] failed to touch generated barrels: ${err.message ?? err}`
239
+ );
240
+ }
239
241
  }
240
242
  }
241
243
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/configs/cli.ts"],
4
- "sourcesContent": ["import type { ModuleCli } from '@open-mercato/shared/modules/registry'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { ModuleConfigService } from './lib/module-config-service'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { DEFAULT_NOTIFICATION_DELIVERY_CONFIG, NOTIFICATIONS_DELIVERY_CONFIG_KEY } from '../notifications/lib/deliveryConfig'\nimport { Tenant } from '../directory/data/entities'\nimport {\n collectCacheStats,\n executeCachePurge,\n previewCachePurge,\n type CachePurgeRequest,\n} from './lib/cache-cli'\nimport { touchGeneratedBarrels } from './lib/touchGeneratedBarrels'\n\ntype ParsedArgs = Record<string, string | boolean>\n\nexport const STRUCTURAL_CACHE_REQUESTS: CachePurgeRequest[] = [\n { kind: 'pattern', pattern: 'nav:*' },\n { kind: 'segment', segment: 'admin-nav' },\n { kind: 'segment', segment: 'portal-nav' },\n]\n\ntype CacheScope = {\n label: string\n tenantId: string | null\n}\n\nfunction parseArgs(rest: string[]): ParsedArgs {\n const args: ParsedArgs = {}\n for (let i = 0; i < rest.length; i += 1) {\n const part = rest[i]\n if (!part?.startsWith('--')) continue\n const [rawKey, rawValue] = part.slice(2).split('=')\n if (!rawKey) continue\n if (rawValue !== undefined) {\n args[rawKey] = rawValue\n } else if (i + 1 < rest.length && !rest[i + 1]!.startsWith('--')) {\n args[rawKey] = rest[i + 1]!\n i += 1\n } else {\n args[rawKey] = true\n }\n }\n return args\n}\n\nfunction stringOption(args: ParsedArgs, ...keys: string[]): string | undefined {\n for (const key of keys) {\n const raw = args[key]\n if (typeof raw !== 'string') continue\n const trimmed = raw.trim()\n if (trimmed.length > 0) return trimmed\n }\n return undefined\n}\n\nfunction flagEnabled(args: ParsedArgs, ...keys: string[]): boolean {\n for (const key of keys) {\n const raw = args[key]\n if (raw === undefined) continue\n if (raw === true) return true\n if (typeof raw === 'string') {\n const parsed = parseBooleanToken(raw)\n return parsed === null ? true : parsed\n }\n }\n return false\n}\n\nfunction splitListOption(raw: string | undefined): string[] {\n if (!raw) return []\n const seen = new Set<string>()\n const values: string[] = []\n for (const item of raw.split(',')) {\n const trimmed = item.trim()\n if (!trimmed || seen.has(trimmed)) continue\n seen.add(trimmed)\n values.push(trimmed)\n }\n return values\n}\n\nasync function resolveCacheScopes(\n em: EntityManager,\n args: ParsedArgs,\n): Promise<CacheScope[]> {\n const explicitTenantId = stringOption(args, 'tenant', 'tenantId')\n const globalOnly = flagEnabled(args, 'global')\n const allTenants = flagEnabled(args, 'all-tenants', 'allTenants')\n\n if (explicitTenantId && globalOnly) {\n throw new Error('Cannot combine `--tenant` with `--global`.')\n }\n if (explicitTenantId && allTenants) {\n throw new Error('Cannot combine `--tenant` with `--all-tenants`.')\n }\n if (globalOnly && allTenants) {\n throw new Error('Cannot combine `--global` with `--all-tenants`.')\n }\n\n if (explicitTenantId) {\n return [{ label: `tenant:${explicitTenantId}`, tenantId: explicitTenantId }]\n }\n\n if (globalOnly) {\n return [{ label: 'global', tenantId: null }]\n }\n\n if (!allTenants) {\n return [{ label: 'global', tenantId: null }]\n }\n\n const tenants = await em.find(Tenant, { deletedAt: null }, { orderBy: { name: 'asc' } })\n const scopes: CacheScope[] = [{ label: 'global', tenantId: null }]\n const seen = new Set<string>()\n for (const tenant of tenants) {\n const tenantId = typeof tenant.id === 'string' ? tenant.id : ''\n if (!tenantId || seen.has(tenantId)) continue\n seen.add(tenantId)\n scopes.push({ label: `tenant:${tenantId}`, tenantId })\n }\n return scopes\n}\n\nfunction resolveCachePurgeRequest(args: ParsedArgs): CachePurgeRequest {\n if (flagEnabled(args, 'all')) return { kind: 'all' }\n\n const segment = stringOption(args, 'segment')\n if (segment) return { kind: 'segment', segment }\n\n const tags = splitListOption(stringOption(args, 'tag', 'tags'))\n if (tags.length > 0) return { kind: 'tags', tags }\n\n const keys = splitListOption(stringOption(args, 'key', 'keys'))\n if (keys.length > 0) return { kind: 'keys', keys }\n\n const ids = splitListOption(stringOption(args, 'id', 'ids'))\n if (ids.length > 0) return { kind: 'ids', ids }\n\n const pattern = stringOption(args, 'pattern')\n if (pattern) return { kind: 'pattern', pattern }\n\n throw new Error(\n 'Choose a purge target: `--all`, `--segment <id>`, `--tag <tag1,tag2>`, `--key <key1,key2>`, `--id <token1,token2>`, or `--pattern <glob>`.',\n )\n}\n\nfunction printCacheHelp() {\n console.log('\uD83E\uDDF9 Cache CLI')\n console.log('')\n console.log('\uD83D\uDE80 Usage:')\n console.log(' yarn mercato configs cache stats [--tenant <id> | --global | --all-tenants] [--json]')\n console.log(' yarn mercato configs cache purge --all [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --segment <segment> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --tag <tag1,tag2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --key <key1,key2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --id <token1,token2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --pattern <glob> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log('')\n console.log('\u2139\uFE0F Notes:')\n console.log(' `stats` mirrors the cache admin page segment overview for CRUD/widget caches.')\n console.log(' `purge --id` removes every key whose name contains the provided token (for example a user id or entity id).')\n console.log(' `structural` targets navigation/sidebar caches and is the recommended post-step after module/sidebar structure changes.')\n console.log(' When no scope flag is supplied, this command uses the global cache scope only.')\n}\n\nasync function disposeContainer(container: unknown) {\n const disposable = container as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n}\n\nasync function runCacheStats(args: ParsedArgs) {\n const json = flagEnabled(args, 'json')\n const container = await createRequestContainer()\n try {\n const em = container.resolve('em') as EntityManager\n const cache = container.resolve('cache') as CacheStrategy\n const scopes = await resolveCacheScopes(em, args)\n const results = []\n for (const scope of scopes) {\n const stats = await runWithCacheTenant(scope.tenantId, async () => collectCacheStats(cache))\n results.push({ scope: scope.label, ...stats })\n }\n\n if (json) {\n console.log(JSON.stringify(results, null, 2))\n return\n }\n\n for (const result of results) {\n console.log(`\uD83D\uDD0E [cache] scope=${result.scope} totalKeys=${result.totalKeys} generatedAt=${result.generatedAt}`)\n if (result.segments.length === 0) {\n console.log(' \u2205 segments: none')\n continue\n }\n for (const segment of result.segments) {\n console.log(` \u2022 ${segment.segment} (${segment.keyCount})${segment.path ? ` ${segment.path}` : ''}`)\n }\n }\n } finally {\n await disposeContainer(container)\n }\n}\n\nasync function runCachePurgeRequest(\n args: ParsedArgs,\n request: CachePurgeRequest,\n emitOutput = true,\n) {\n const json = flagEnabled(args, 'json')\n const quiet = flagEnabled(args, 'quiet')\n const dryRun = flagEnabled(args, 'dry-run', 'dryRun')\n const container = await createRequestContainer()\n try {\n const em = container.resolve('em') as EntityManager\n const cache = container.resolve('cache') as CacheStrategy\n const scopes = await resolveCacheScopes(em, args)\n const results = []\n\n for (const scope of scopes) {\n const result = await runWithCacheTenant(scope.tenantId, async () =>\n dryRun ? previewCachePurge(cache, request) : executeCachePurge(cache, request)\n )\n results.push({\n scope: scope.label,\n dryRun,\n request,\n deleted: result.deleted,\n keyCount: result.keys.length,\n keys: result.keys,\n note: result.note,\n })\n }\n\n if (json && emitOutput) {\n console.log(JSON.stringify(results, null, 2))\n return results\n }\n\n if (quiet || !emitOutput) {\n return results\n }\n\n for (const result of results) {\n console.log(`${result.dryRun ? '\uD83E\uDDEA' : '\uD83E\uDDF9'} [cache] scope=${result.scope} deleted=${result.deleted}${result.dryRun ? ' (dry-run)' : ''}`)\n if (result.note) console.log(` \u2139\uFE0F note: ${result.note}`)\n if (result.keys.length > 0) {\n for (const key of result.keys) {\n console.log(` \u2022 ${key}`)\n }\n }\n }\n return results\n } finally {\n await disposeContainer(container)\n }\n}\n\nasync function runCachePurge(args: ParsedArgs) {\n await runCachePurgeRequest(args, resolveCachePurgeRequest(args))\n}\n\nasync function runStructuralCachePurge(args: ParsedArgs) {\n const json = flagEnabled(args, 'json')\n const structuralResults: Array<{\n request: CachePurgeRequest\n results: Awaited<ReturnType<typeof runCachePurgeRequest>>\n }> = []\n for (const request of STRUCTURAL_CACHE_REQUESTS) {\n const results = await runCachePurgeRequest(args, request, !json)\n structuralResults.push({ request, results })\n }\n if (json) {\n console.log(JSON.stringify(structuralResults, null, 2))\n }\n const quiet = flagEnabled(args, 'quiet')\n try {\n touchGeneratedBarrels({ quiet: quiet || json })\n } catch (err) {\n if (!quiet && !json) {\n console.warn(\n `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,\n )\n }\n }\n}\n\nfunction envDisablesAutoIndexing(): boolean {\n const raw =\n process.env.OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING ??\n process.env.DISABLE_VECTOR_SEARCH_AUTOINDEXING\n if (!raw) return false\n return parseBooleanToken(raw) === true\n}\n\nconst restoreDefaults: ModuleCli = {\n command: 'restore-defaults',\n async run() {\n const container = await createRequestContainer()\n try {\n let service: ModuleConfigService\n try {\n service = (container.resolve('moduleConfigService') as ModuleConfigService)\n } catch {\n console.error('[configs] moduleConfigService is not registered in the container.')\n return\n }\n\n const disabledByEnv = envDisablesAutoIndexing()\n const defaultEnabled = !disabledByEnv\n await service.restoreDefaults(\n [\n {\n moduleId: 'vector',\n name: 'auto_index_enabled',\n value: defaultEnabled,\n },\n {\n moduleId: 'notifications',\n name: NOTIFICATIONS_DELIVERY_CONFIG_KEY,\n value: DEFAULT_NOTIFICATION_DELIVERY_CONFIG,\n },\n ],\n { force: true },\n )\n console.log(\n `[configs] Vector auto-indexing default set to ${defaultEnabled ? 'enabled' : 'disabled'}${\n disabledByEnv\n ? ' (forced by OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING or legacy DISABLE_VECTOR_SEARCH_AUTOINDEXING)'\n : ''\n }.`,\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n },\n}\n\nconst help: ModuleCli = {\n command: 'help',\n async run() {\n console.log('\u2699\uFE0F Configs CLI')\n console.log('')\n console.log('\uD83D\uDE80 Usage: yarn mercato configs restore-defaults')\n console.log(' Ensures global module configuration defaults exist.')\n console.log('')\n printCacheHelp()\n },\n}\n\nconst cacheCommand: ModuleCli = {\n command: 'cache',\n async run(rest) {\n const [subcommand, ...subRest] = rest\n const args = parseArgs(subRest)\n\n if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {\n printCacheHelp()\n return\n }\n\n if (subcommand === 'stats') {\n await runCacheStats(args)\n return\n }\n\n if (subcommand === 'purge') {\n await runCachePurge(args)\n return\n }\n\n if (subcommand === 'structural') {\n await runStructuralCachePurge(args)\n return\n }\n\n throw new Error(`Unknown cache subcommand \"${subcommand}\".`)\n },\n}\n\nexport default [restoreDefaults, cacheCommand, help]\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { ModuleCli } from '@open-mercato/shared/modules/registry'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { ModuleConfigService } from './lib/module-config-service'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { DEFAULT_NOTIFICATION_DELIVERY_CONFIG, NOTIFICATIONS_DELIVERY_CONFIG_KEY } from '../notifications/lib/deliveryConfig'\nimport { Tenant } from '../directory/data/entities'\nimport {\n collectCacheStats,\n executeCachePurge,\n previewCachePurge,\n type CachePurgeRequest,\n} from './lib/cache-cli'\nimport { touchGeneratedBarrels } from './lib/touchGeneratedBarrels'\n\ntype ParsedArgs = Record<string, string | boolean>\n\nexport const STRUCTURAL_CACHE_REQUESTS: CachePurgeRequest[] = [\n { kind: 'pattern', pattern: 'nav:*' },\n { kind: 'segment', segment: 'admin-nav' },\n { kind: 'segment', segment: 'portal-nav' },\n]\n\ntype CacheScope = {\n label: string\n tenantId: string | null\n}\n\nfunction parseArgs(rest: string[]): ParsedArgs {\n const args: ParsedArgs = {}\n for (let i = 0; i < rest.length; i += 1) {\n const part = rest[i]\n if (!part?.startsWith('--')) continue\n const [rawKey, rawValue] = part.slice(2).split('=')\n if (!rawKey) continue\n if (rawValue !== undefined) {\n args[rawKey] = rawValue\n } else if (i + 1 < rest.length && !rest[i + 1]!.startsWith('--')) {\n args[rawKey] = rest[i + 1]!\n i += 1\n } else {\n args[rawKey] = true\n }\n }\n return args\n}\n\nfunction stringOption(args: ParsedArgs, ...keys: string[]): string | undefined {\n for (const key of keys) {\n const raw = args[key]\n if (typeof raw !== 'string') continue\n const trimmed = raw.trim()\n if (trimmed.length > 0) return trimmed\n }\n return undefined\n}\n\nfunction flagEnabled(args: ParsedArgs, ...keys: string[]): boolean {\n for (const key of keys) {\n const raw = args[key]\n if (raw === undefined) continue\n if (raw === true) return true\n if (typeof raw === 'string') {\n const parsed = parseBooleanToken(raw)\n return parsed === null ? true : parsed\n }\n }\n return false\n}\n\nfunction splitListOption(raw: string | undefined): string[] {\n if (!raw) return []\n const seen = new Set<string>()\n const values: string[] = []\n for (const item of raw.split(',')) {\n const trimmed = item.trim()\n if (!trimmed || seen.has(trimmed)) continue\n seen.add(trimmed)\n values.push(trimmed)\n }\n return values\n}\n\nasync function resolveCacheScopes(\n em: EntityManager,\n args: ParsedArgs,\n): Promise<CacheScope[]> {\n const explicitTenantId = stringOption(args, 'tenant', 'tenantId')\n const globalOnly = flagEnabled(args, 'global')\n const allTenants = flagEnabled(args, 'all-tenants', 'allTenants')\n\n if (explicitTenantId && globalOnly) {\n throw new Error('Cannot combine `--tenant` with `--global`.')\n }\n if (explicitTenantId && allTenants) {\n throw new Error('Cannot combine `--tenant` with `--all-tenants`.')\n }\n if (globalOnly && allTenants) {\n throw new Error('Cannot combine `--global` with `--all-tenants`.')\n }\n\n if (explicitTenantId) {\n return [{ label: `tenant:${explicitTenantId}`, tenantId: explicitTenantId }]\n }\n\n if (globalOnly) {\n return [{ label: 'global', tenantId: null }]\n }\n\n if (!allTenants) {\n return [{ label: 'global', tenantId: null }]\n }\n\n const tenants = await em.find(Tenant, { deletedAt: null }, { orderBy: { name: 'asc' } })\n const scopes: CacheScope[] = [{ label: 'global', tenantId: null }]\n const seen = new Set<string>()\n for (const tenant of tenants) {\n const tenantId = typeof tenant.id === 'string' ? tenant.id : ''\n if (!tenantId || seen.has(tenantId)) continue\n seen.add(tenantId)\n scopes.push({ label: `tenant:${tenantId}`, tenantId })\n }\n return scopes\n}\n\nfunction resolveCachePurgeRequest(args: ParsedArgs): CachePurgeRequest {\n if (flagEnabled(args, 'all')) return { kind: 'all' }\n\n const segment = stringOption(args, 'segment')\n if (segment) return { kind: 'segment', segment }\n\n const tags = splitListOption(stringOption(args, 'tag', 'tags'))\n if (tags.length > 0) return { kind: 'tags', tags }\n\n const keys = splitListOption(stringOption(args, 'key', 'keys'))\n if (keys.length > 0) return { kind: 'keys', keys }\n\n const ids = splitListOption(stringOption(args, 'id', 'ids'))\n if (ids.length > 0) return { kind: 'ids', ids }\n\n const pattern = stringOption(args, 'pattern')\n if (pattern) return { kind: 'pattern', pattern }\n\n throw new Error(\n 'Choose a purge target: `--all`, `--segment <id>`, `--tag <tag1,tag2>`, `--key <key1,key2>`, `--id <token1,token2>`, or `--pattern <glob>`.',\n )\n}\n\nfunction printCacheHelp() {\n console.log('\uD83E\uDDF9 Cache CLI')\n console.log('')\n console.log('\uD83D\uDE80 Usage:')\n console.log(' yarn mercato configs cache stats [--tenant <id> | --global | --all-tenants] [--json]')\n console.log(' yarn mercato configs cache purge --all [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --segment <segment> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --tag <tag1,tag2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --key <key1,key2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --id <token1,token2> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache purge --pattern <glob> [--tenant <id> | --global | --all-tenants] [--dry-run] [--json]')\n console.log(' yarn mercato configs cache structural [--tenant <id> | --global | --all-tenants] [--touch-generated] [--dry-run] [--json]')\n console.log('')\n console.log('\u2139\uFE0F Notes:')\n console.log(' `stats` mirrors the cache admin page segment overview for CRUD/widget caches.')\n console.log(' `purge --id` removes every key whose name contains the provided token (for example a user id or entity id).')\n console.log(' `structural` targets navigation/sidebar caches. Add `--touch-generated` only for explicit stale-compiler recovery.')\n console.log(' When no scope flag is supplied, this command uses the global cache scope only.')\n}\n\nasync function disposeContainer(container: unknown) {\n const disposable = container as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n}\n\nasync function runCacheStats(args: ParsedArgs) {\n const json = flagEnabled(args, 'json')\n const container = await createRequestContainer()\n try {\n const em = container.resolve('em') as EntityManager\n const cache = container.resolve('cache') as CacheStrategy\n const scopes = await resolveCacheScopes(em, args)\n const results = []\n for (const scope of scopes) {\n const stats = await runWithCacheTenant(scope.tenantId, async () => collectCacheStats(cache))\n results.push({ scope: scope.label, ...stats })\n }\n\n if (json) {\n console.log(JSON.stringify(results, null, 2))\n return\n }\n\n for (const result of results) {\n console.log(`\uD83D\uDD0E [cache] scope=${result.scope} totalKeys=${result.totalKeys} generatedAt=${result.generatedAt}`)\n if (result.segments.length === 0) {\n console.log(' \u2205 segments: none')\n continue\n }\n for (const segment of result.segments) {\n console.log(` \u2022 ${segment.segment} (${segment.keyCount})${segment.path ? ` ${segment.path}` : ''}`)\n }\n }\n } finally {\n await disposeContainer(container)\n }\n}\n\nasync function runCachePurgeRequest(\n args: ParsedArgs,\n request: CachePurgeRequest,\n emitOutput = true,\n) {\n const json = flagEnabled(args, 'json')\n const quiet = flagEnabled(args, 'quiet')\n const dryRun = flagEnabled(args, 'dry-run', 'dryRun')\n const container = await createRequestContainer()\n try {\n const em = container.resolve('em') as EntityManager\n const cache = container.resolve('cache') as CacheStrategy\n const scopes = await resolveCacheScopes(em, args)\n const results = []\n\n for (const scope of scopes) {\n const result = await runWithCacheTenant(scope.tenantId, async () =>\n dryRun ? previewCachePurge(cache, request) : executeCachePurge(cache, request)\n )\n results.push({\n scope: scope.label,\n dryRun,\n request,\n deleted: result.deleted,\n keyCount: result.keys.length,\n keys: result.keys,\n note: result.note,\n })\n }\n\n if (json && emitOutput) {\n console.log(JSON.stringify(results, null, 2))\n return results\n }\n\n if (quiet || !emitOutput) {\n return results\n }\n\n for (const result of results) {\n console.log(`${result.dryRun ? '\uD83E\uDDEA' : '\uD83E\uDDF9'} [cache] scope=${result.scope} deleted=${result.deleted}${result.dryRun ? ' (dry-run)' : ''}`)\n if (result.note) console.log(` \u2139\uFE0F note: ${result.note}`)\n if (result.keys.length > 0) {\n for (const key of result.keys) {\n console.log(` \u2022 ${key}`)\n }\n }\n }\n return results\n } finally {\n await disposeContainer(container)\n }\n}\n\nasync function runCachePurge(args: ParsedArgs) {\n await runCachePurgeRequest(args, resolveCachePurgeRequest(args))\n}\n\nasync function runStructuralCachePurge(args: ParsedArgs) {\n const json = flagEnabled(args, 'json')\n const structuralResults: Array<{\n request: CachePurgeRequest\n results: Awaited<ReturnType<typeof runCachePurgeRequest>>\n }> = []\n for (const request of STRUCTURAL_CACHE_REQUESTS) {\n const results = await runCachePurgeRequest(args, request, !json)\n structuralResults.push({ request, results })\n }\n if (json) {\n console.log(JSON.stringify(structuralResults, null, 2))\n }\n if (flagEnabled(args, 'touch-generated', 'touchGenerated')) {\n const quiet = flagEnabled(args, 'quiet')\n try {\n touchGeneratedBarrels({ quiet: quiet || json })\n } catch (err) {\n if (!quiet && !json) {\n console.warn(\n `[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,\n )\n }\n }\n }\n}\n\nfunction envDisablesAutoIndexing(): boolean {\n const raw =\n process.env.OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING ??\n process.env.DISABLE_VECTOR_SEARCH_AUTOINDEXING\n if (!raw) return false\n return parseBooleanToken(raw) === true\n}\n\nconst restoreDefaults: ModuleCli = {\n command: 'restore-defaults',\n async run() {\n const container = await createRequestContainer()\n try {\n let service: ModuleConfigService\n try {\n service = (container.resolve('moduleConfigService') as ModuleConfigService)\n } catch {\n console.error('[configs] moduleConfigService is not registered in the container.')\n return\n }\n\n const disabledByEnv = envDisablesAutoIndexing()\n const defaultEnabled = !disabledByEnv\n await service.restoreDefaults(\n [\n {\n moduleId: 'vector',\n name: 'auto_index_enabled',\n value: defaultEnabled,\n },\n {\n moduleId: 'notifications',\n name: NOTIFICATIONS_DELIVERY_CONFIG_KEY,\n value: DEFAULT_NOTIFICATION_DELIVERY_CONFIG,\n },\n ],\n { force: true },\n )\n console.log(\n `[configs] Vector auto-indexing default set to ${defaultEnabled ? 'enabled' : 'disabled'}${\n disabledByEnv\n ? ' (forced by OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING or legacy DISABLE_VECTOR_SEARCH_AUTOINDEXING)'\n : ''\n }.`,\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n },\n}\n\nconst help: ModuleCli = {\n command: 'help',\n async run() {\n console.log('\u2699\uFE0F Configs CLI')\n console.log('')\n console.log('\uD83D\uDE80 Usage: yarn mercato configs restore-defaults')\n console.log(' Ensures global module configuration defaults exist.')\n console.log('')\n printCacheHelp()\n },\n}\n\nconst cacheCommand: ModuleCli = {\n command: 'cache',\n async run(rest) {\n const [subcommand, ...subRest] = rest\n const args = parseArgs(subRest)\n\n if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {\n printCacheHelp()\n return\n }\n\n if (subcommand === 'stats') {\n await runCacheStats(args)\n return\n }\n\n if (subcommand === 'purge') {\n await runCachePurge(args)\n return\n }\n\n if (subcommand === 'structural') {\n await runStructuralCachePurge(args)\n return\n }\n\n throw new Error(`Unknown cache subcommand \"${subcommand}\".`)\n },\n}\n\nexport default [restoreDefaults, cacheCommand, help]\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -26,13 +26,13 @@ function touchGeneratedBarrels(options = {}) {
26
26
  return { generatedDir: null, files: [] };
27
27
  }
28
28
  const touched = [];
29
+ const touchedAt = /* @__PURE__ */ new Date();
29
30
  const entries = fs.readdirSync(generatedDir, { withFileTypes: true });
30
31
  for (const entry of entries) {
31
32
  if (!entry.isFile()) continue;
32
33
  if (!TOUCHABLE_PATTERN.test(entry.name)) continue;
33
34
  const filePath = path.join(generatedDir, entry.name);
34
- const contents = fs.readFileSync(filePath);
35
- fs.writeFileSync(filePath, contents);
35
+ fs.utimesSync(filePath, touchedAt, touchedAt);
36
36
  touched.push(filePath);
37
37
  }
38
38
  if (!quiet && touched.length > 0) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/configs/lib/touchGeneratedBarrels.ts"],
4
- "sourcesContent": ["import fs from 'node:fs'\nimport path from 'node:path'\n\nconst GENERATED_DIR_RELATIVE = path.join('.mercato', 'generated')\nconst TOUCHABLE_PATTERN = /\\.generated(?:\\.[a-z0-9]+)?(?:\\.ts|\\.checksum)$/i\nconst MAX_PARENT_WALK = 5\n\nexport type TouchGeneratedBarrelsOptions = {\n cwd?: string\n quiet?: boolean\n log?: (message: string) => void\n}\n\nexport type TouchGeneratedBarrelsResult = {\n generatedDir: string | null\n files: string[]\n}\n\nexport function findGeneratedDir(startDir: string): string | null {\n let current = path.resolve(startDir)\n for (let depth = 0; depth <= MAX_PARENT_WALK; depth += 1) {\n const candidate = path.join(current, GENERATED_DIR_RELATIVE)\n if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {\n return candidate\n }\n const parent = path.dirname(current)\n if (parent === current) break\n current = parent\n }\n return null\n}\n\nexport function touchGeneratedBarrels(\n options: TouchGeneratedBarrelsOptions = {},\n): TouchGeneratedBarrelsResult {\n const cwd = options.cwd ?? process.cwd()\n const log = options.log ?? ((message: string) => process.stdout.write(`${message}\\n`))\n const quiet = options.quiet === true\n\n const generatedDir = findGeneratedDir(cwd)\n if (!generatedDir) {\n return { generatedDir: null, files: [] }\n }\n\n const touched: string[] = []\n const entries = fs.readdirSync(generatedDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isFile()) continue\n if (!TOUCHABLE_PATTERN.test(entry.name)) continue\n const filePath = path.join(generatedDir, entry.name)\n const contents = fs.readFileSync(filePath)\n fs.writeFileSync(filePath, contents)\n touched.push(filePath)\n }\n\n if (!quiet && touched.length > 0) {\n log(`\uD83D\uDD01 [structural] touched ${touched.length} generated barrel(s) \u2192 ${generatedDir}`)\n }\n\n return { generatedDir, files: touched }\n}\n"],
5
- "mappings": "AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAEjB,MAAM,yBAAyB,KAAK,KAAK,YAAY,WAAW;AAChE,MAAM,oBAAoB;AAC1B,MAAM,kBAAkB;AAajB,SAAS,iBAAiB,UAAiC;AAChE,MAAI,UAAU,KAAK,QAAQ,QAAQ;AACnC,WAAS,QAAQ,GAAG,SAAS,iBAAiB,SAAS,GAAG;AACxD,UAAM,YAAY,KAAK,KAAK,SAAS,sBAAsB;AAC3D,QAAI,GAAG,WAAW,SAAS,KAAK,GAAG,SAAS,SAAS,EAAE,YAAY,GAAG;AACpE,aAAO;AAAA,IACT;AACA,UAAM,SAAS,KAAK,QAAQ,OAAO;AACnC,QAAI,WAAW,QAAS;AACxB,cAAU;AAAA,EACZ;AACA,SAAO;AACT;AAEO,SAAS,sBACd,UAAwC,CAAC,GACZ;AAC7B,QAAM,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACvC,QAAM,MAAM,QAAQ,QAAQ,CAAC,YAAoB,QAAQ,OAAO,MAAM,GAAG,OAAO;AAAA,CAAI;AACpF,QAAM,QAAQ,QAAQ,UAAU;AAEhC,QAAM,eAAe,iBAAiB,GAAG;AACzC,MAAI,CAAC,cAAc;AACjB,WAAO,EAAE,cAAc,MAAM,OAAO,CAAC,EAAE;AAAA,EACzC;AAEA,QAAM,UAAoB,CAAC;AAC3B,QAAM,UAAU,GAAG,YAAY,cAAc,EAAE,eAAe,KAAK,CAAC;AACpE,aAAW,SAAS,SAAS;AAC3B,QAAI,CAAC,MAAM,OAAO,EAAG;AACrB,QAAI,CAAC,kBAAkB,KAAK,MAAM,IAAI,EAAG;AACzC,UAAM,WAAW,KAAK,KAAK,cAAc,MAAM,IAAI;AACnD,UAAM,WAAW,GAAG,aAAa,QAAQ;AACzC,OAAG,cAAc,UAAU,QAAQ;AACnC,YAAQ,KAAK,QAAQ;AAAA,EACvB;AAEA,MAAI,CAAC,SAAS,QAAQ,SAAS,GAAG;AAChC,QAAI,kCAA2B,QAAQ,MAAM,+BAA0B,YAAY,EAAE;AAAA,EACvF;AAEA,SAAO,EAAE,cAAc,OAAO,QAAQ;AACxC;",
4
+ "sourcesContent": ["import fs from 'node:fs'\nimport path from 'node:path'\n\nconst GENERATED_DIR_RELATIVE = path.join('.mercato', 'generated')\nconst TOUCHABLE_PATTERN = /\\.generated(?:\\.[a-z0-9]+)?(?:\\.ts|\\.checksum)$/i\nconst MAX_PARENT_WALK = 5\n\nexport type TouchGeneratedBarrelsOptions = {\n cwd?: string\n quiet?: boolean\n log?: (message: string) => void\n}\n\nexport type TouchGeneratedBarrelsResult = {\n generatedDir: string | null\n files: string[]\n}\n\nexport function findGeneratedDir(startDir: string): string | null {\n let current = path.resolve(startDir)\n for (let depth = 0; depth <= MAX_PARENT_WALK; depth += 1) {\n const candidate = path.join(current, GENERATED_DIR_RELATIVE)\n if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {\n return candidate\n }\n const parent = path.dirname(current)\n if (parent === current) break\n current = parent\n }\n return null\n}\n\nexport function touchGeneratedBarrels(\n options: TouchGeneratedBarrelsOptions = {},\n): TouchGeneratedBarrelsResult {\n const cwd = options.cwd ?? process.cwd()\n const log = options.log ?? ((message: string) => process.stdout.write(`${message}\\n`))\n const quiet = options.quiet === true\n\n const generatedDir = findGeneratedDir(cwd)\n if (!generatedDir) {\n return { generatedDir: null, files: [] }\n }\n\n const touched: string[] = []\n const touchedAt = new Date()\n const entries = fs.readdirSync(generatedDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isFile()) continue\n if (!TOUCHABLE_PATTERN.test(entry.name)) continue\n const filePath = path.join(generatedDir, entry.name)\n // Advance mtime without rewriting the bytes: rewriting truncates then refills\n // multi-megabyte registries, and a concurrent Turbopack compile reading one\n // mid-write sees a partial file. Matches `packages/cli/src/lib/post-generate-invalidation.ts`.\n fs.utimesSync(filePath, touchedAt, touchedAt)\n touched.push(filePath)\n }\n\n if (!quiet && touched.length > 0) {\n log(`\uD83D\uDD01 [structural] touched ${touched.length} generated barrel(s) \u2192 ${generatedDir}`)\n }\n\n return { generatedDir, files: touched }\n}\n"],
5
+ "mappings": "AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAEjB,MAAM,yBAAyB,KAAK,KAAK,YAAY,WAAW;AAChE,MAAM,oBAAoB;AAC1B,MAAM,kBAAkB;AAajB,SAAS,iBAAiB,UAAiC;AAChE,MAAI,UAAU,KAAK,QAAQ,QAAQ;AACnC,WAAS,QAAQ,GAAG,SAAS,iBAAiB,SAAS,GAAG;AACxD,UAAM,YAAY,KAAK,KAAK,SAAS,sBAAsB;AAC3D,QAAI,GAAG,WAAW,SAAS,KAAK,GAAG,SAAS,SAAS,EAAE,YAAY,GAAG;AACpE,aAAO;AAAA,IACT;AACA,UAAM,SAAS,KAAK,QAAQ,OAAO;AACnC,QAAI,WAAW,QAAS;AACxB,cAAU;AAAA,EACZ;AACA,SAAO;AACT;AAEO,SAAS,sBACd,UAAwC,CAAC,GACZ;AAC7B,QAAM,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACvC,QAAM,MAAM,QAAQ,QAAQ,CAAC,YAAoB,QAAQ,OAAO,MAAM,GAAG,OAAO;AAAA,CAAI;AACpF,QAAM,QAAQ,QAAQ,UAAU;AAEhC,QAAM,eAAe,iBAAiB,GAAG;AACzC,MAAI,CAAC,cAAc;AACjB,WAAO,EAAE,cAAc,MAAM,OAAO,CAAC,EAAE;AAAA,EACzC;AAEA,QAAM,UAAoB,CAAC;AAC3B,QAAM,YAAY,oBAAI,KAAK;AAC3B,QAAM,UAAU,GAAG,YAAY,cAAc,EAAE,eAAe,KAAK,CAAC;AACpE,aAAW,SAAS,SAAS;AAC3B,QAAI,CAAC,MAAM,OAAO,EAAG;AACrB,QAAI,CAAC,kBAAkB,KAAK,MAAM,IAAI,EAAG;AACzC,UAAM,WAAW,KAAK,KAAK,cAAc,MAAM,IAAI;AAInD,OAAG,WAAW,UAAU,WAAW,SAAS;AAC5C,YAAQ,KAAK,QAAQ;AAAA,EACvB;AAEA,MAAI,CAAC,SAAS,QAAQ,SAAS,GAAG;AAChC,QAAI,kCAA2B,QAAQ,MAAM,+BAA0B,YAAY,EAAE;AAAA,EACvF;AAEA,SAAO,EAAE,cAAc,OAAO,QAAQ;AACxC;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- import { MessageObjectDetail, MessageObjectPreview } from "@open-mercato/ui";
1
+ import { MessageObjectDetail, MessageObjectPreview } from "@open-mercato/ui/backend/messages";
2
2
  const objectMessageTypes = ["default", "messages.defaultWithObjects"];
3
3
  const messageObjectTypes = [
4
4
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/customers/message-objects.ts"],
4
- "sourcesContent": ["import type { MessageObjectTypeDefinition } from '@open-mercato/shared/modules/messages/types'\nimport { MessageObjectDetail, MessageObjectPreview } from '@open-mercato/ui'\n\nconst objectMessageTypes = ['default', 'messages.defaultWithObjects']\n\nexport const messageObjectTypes: MessageObjectTypeDefinition[] = [\n {\n module: 'customers',\n entityType: 'person',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_person_profile',\n optionLabelField: 'name',\n optionSubtitleField: 'email',\n labelKey: 'customers.people.list.title',\n icon: 'user-round',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/people/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Person', subtitle: entityId }\n }\n const { loadCustomerPersonPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerPersonPreview(entityId, ctx)\n },\n },\n {\n module: 'customers',\n entityType: 'company',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_company_profile',\n optionLabelField: 'name',\n optionSubtitleField: 'taxId',\n labelKey: 'customers.companies.list.title',\n icon: 'building2',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/companies/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Company', subtitle: entityId }\n }\n const { loadCustomerCompanyPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerCompanyPreview(entityId, ctx)\n },\n },\n {\n module: 'customers',\n entityType: 'deal',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_deal',\n optionLabelField: 'title',\n optionSubtitleField: 'status',\n labelKey: 'customers.deals.list.title',\n icon: 'briefcase-business',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/deals/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Deal', subtitle: entityId }\n }\n const { loadCustomerDealPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerDealPreview(entityId, ctx)\n },\n },\n]\n\nexport default messageObjectTypes\n"],
4
+ "sourcesContent": ["import type { MessageObjectTypeDefinition } from '@open-mercato/shared/modules/messages/types'\nimport { MessageObjectDetail, MessageObjectPreview } from '@open-mercato/ui/backend/messages'\n\nconst objectMessageTypes = ['default', 'messages.defaultWithObjects']\n\nexport const messageObjectTypes: MessageObjectTypeDefinition[] = [\n {\n module: 'customers',\n entityType: 'person',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_person_profile',\n optionLabelField: 'name',\n optionSubtitleField: 'email',\n labelKey: 'customers.people.list.title',\n icon: 'user-round',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/people/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Person', subtitle: entityId }\n }\n const { loadCustomerPersonPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerPersonPreview(entityId, ctx)\n },\n },\n {\n module: 'customers',\n entityType: 'company',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_company_profile',\n optionLabelField: 'name',\n optionSubtitleField: 'taxId',\n labelKey: 'customers.companies.list.title',\n icon: 'building2',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/companies/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Company', subtitle: entityId }\n }\n const { loadCustomerCompanyPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerCompanyPreview(entityId, ctx)\n },\n },\n {\n module: 'customers',\n entityType: 'deal',\n messageTypes: objectMessageTypes,\n entityId: 'customers:customer_deal',\n optionLabelField: 'title',\n optionSubtitleField: 'status',\n labelKey: 'customers.deals.list.title',\n icon: 'briefcase-business',\n PreviewComponent: MessageObjectPreview,\n DetailComponent: MessageObjectDetail,\n actions: [\n {\n id: 'view',\n labelKey: 'common.view',\n variant: 'outline',\n href: '/backend/customers/deals/{entityId}',\n },\n ],\n loadPreview: async (entityId, ctx) => {\n if (typeof window !== 'undefined') {\n return { title: 'Deal', subtitle: entityId }\n }\n const { loadCustomerDealPreview } = await import('./lib/messageObjectPreviews')\n return loadCustomerDealPreview(entityId, ctx)\n },\n },\n]\n\nexport default messageObjectTypes\n"],
5
5
  "mappings": "AACA,SAAS,qBAAqB,4BAA4B;AAE1D,MAAM,qBAAqB,CAAC,WAAW,6BAA6B;AAE7D,MAAM,qBAAoD;AAAA,EAC/D;AAAA,IACE,QAAQ;AAAA,IACR,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,UAAU;AAAA,IACV,kBAAkB;AAAA,IAClB,qBAAqB;AAAA,IACrB,UAAU;AAAA,IACV,MAAM;AAAA,IACN,kBAAkB;AAAA,IAClB,iBAAiB;AAAA,IACjB,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,aAAa,OAAO,UAAU,QAAQ;AACpC,UAAI,OAAO,WAAW,aAAa;AACjC,eAAO,EAAE,OAAO,UAAU,UAAU,SAAS;AAAA,MAC/C;AACA,YAAM,EAAE,0BAA0B,IAAI,MAAM,OAAO,6BAA6B;AAChF,aAAO,0BAA0B,UAAU,GAAG;AAAA,IAChD;AAAA,EACF;AAAA,EACA;AAAA,IACE,QAAQ;AAAA,IACR,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,UAAU;AAAA,IACV,kBAAkB;AAAA,IAClB,qBAAqB;AAAA,IACrB,UAAU;AAAA,IACV,MAAM;AAAA,IACN,kBAAkB;AAAA,IAClB,iBAAiB;AAAA,IACjB,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,aAAa,OAAO,UAAU,QAAQ;AACpC,UAAI,OAAO,WAAW,aAAa;AACjC,eAAO,EAAE,OAAO,WAAW,UAAU,SAAS;AAAA,MAChD;AACA,YAAM,EAAE,2BAA2B,IAAI,MAAM,OAAO,6BAA6B;AACjF,aAAO,2BAA2B,UAAU,GAAG;AAAA,IACjD;AAAA,EACF;AAAA,EACA;AAAA,IACE,QAAQ;AAAA,IACR,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,UAAU;AAAA,IACV,kBAAkB;AAAA,IAClB,qBAAqB;AAAA,IACrB,UAAU;AAAA,IACV,MAAM;AAAA,IACN,kBAAkB;AAAA,IAClB,iBAAiB;AAAA,IACjB,SAAS;AAAA,MACP;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,SAAS;AAAA,QACT,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,aAAa,OAAO,UAAU,QAAQ;AACpC,UAAI,OAAO,WAAW,aAAa;AACjC,eAAO,EAAE,OAAO,QAAQ,UAAU,SAAS;AAAA,MAC7C;AACA,YAAM,EAAE,wBAAwB,IAAI,MAAM,OAAO,6BAA6B;AAC9E,aAAO,wBAAwB,UAAU,GAAG;AAAA,IAC9C;AAAA,EACF;AACF;AAEA,IAAO,0BAAQ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.6889.1.af4501ca97",
3
+ "version": "0.6.8-develop.6892.1.f179677788",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.6889.1.af4501ca97",
258
- "@open-mercato/shared": "0.6.8-develop.6889.1.af4501ca97",
259
- "@open-mercato/ui": "0.6.8-develop.6889.1.af4501ca97",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6892.1.f179677788",
258
+ "@open-mercato/shared": "0.6.8-develop.6892.1.f179677788",
259
+ "@open-mercato/ui": "0.6.8-develop.6892.1.f179677788",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.6889.1.af4501ca97",
265
- "@open-mercato/shared": "0.6.8-develop.6889.1.af4501ca97",
266
- "@open-mercato/ui": "0.6.8-develop.6889.1.af4501ca97",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6892.1.f179677788",
265
+ "@open-mercato/shared": "0.6.8-develop.6892.1.f179677788",
266
+ "@open-mercato/ui": "0.6.8-develop.6892.1.f179677788",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.1",
@@ -4,6 +4,8 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import type { EntityManager } from '@mikro-orm/postgresql'
5
5
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
6
  import { CommunicationChannel } from '../../../../data/entities'
7
+ import { isHubPolledChannel } from '../../../../lib/polling-eligibility'
8
+ import type { ChannelAdapterRegistry } from '../../../../lib/registry'
7
9
 
8
10
  export const metadata = {
9
11
  path: '/communication_channels/me/channels',
@@ -41,13 +43,25 @@ export async function GET(req: Request): Promise<Response> {
41
43
  { tenantId: auth.tenantId as string, organizationId: (auth as { orgId?: string | null }).orgId ?? null },
42
44
  )
43
45
 
46
+ // The profile grid renders "is this channel polled or push-driven?" and
47
+ // "can push be registered here?" — both are adapter facts, not provider names,
48
+ // so they are resolved server-side (#4980).
49
+ let adapterRegistry: ChannelAdapterRegistry | null = null
50
+ try {
51
+ adapterRegistry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry
52
+ } catch {
53
+ adapterRegistry = null
54
+ }
55
+
44
56
  return NextResponse.json({
45
- items: (channels as CommunicationChannel[]).map(serialize),
57
+ items: (channels as CommunicationChannel[]).map((channel) =>
58
+ serialize(channel, adapterRegistry),
59
+ ),
46
60
  total: channels.length,
47
61
  })
48
62
  }
49
63
 
50
- function serialize(channel: CommunicationChannel) {
64
+ function serialize(channel: CommunicationChannel, adapterRegistry: ChannelAdapterRegistry | null) {
51
65
  // Spec C — expose push status + last push error to the operator UI so
52
66
  // the `PushStatusSection` can render the "Re-register push" affordance.
53
67
  const channelState =
@@ -66,6 +80,12 @@ function serialize(channel: CommunicationChannel) {
66
80
  at: channelState.lastPushError.at ?? null,
67
81
  }
68
82
  : null
83
+ // `true` when the hub's poll worker skips this channel because the adapter
84
+ // declares real-time push — the inverse of `isHubPolledChannel`, which the
85
+ // worker itself uses, so the label cannot contradict the behaviour.
86
+ const supportsRealtimePush = !isHubPolledChannel(channel.capabilities)
87
+ const adapter = adapterRegistry?.get(channel.providerKey)
88
+ const supportsPushRegistration = typeof adapter?.registerPush === 'function'
69
89
  return {
70
90
  id: channel.id,
71
91
  providerKey: channel.providerKey,
@@ -80,6 +100,8 @@ function serialize(channel: CommunicationChannel) {
80
100
  lastPolledAt: channel.lastPolledAt?.toISOString?.() ?? null,
81
101
  pushStatus,
82
102
  lastPushError,
103
+ supportsRealtimePush,
104
+ supportsPushRegistration,
83
105
  createdAt: channel.createdAt?.toISOString?.() ?? null,
84
106
  }
85
107
  }
@@ -1,12 +1,14 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
6
  import type { EntityManager } from '@mikro-orm/postgresql'
6
7
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
8
  import { CommunicationChannel } from '../../../../../data/entities'
8
9
  import { ChannelAccessDeniedError, assertCanManageChannel } from '../../../../../lib/access-control'
9
10
  import { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '../../../../../lib/queue'
11
+ import { isHubPolledChannel } from '../../../../../lib/polling-eligibility'
10
12
  import type { PollChannelJobPayload } from '../../../../../workers/poll-channel'
11
13
  import { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'
12
14
 
@@ -118,6 +120,25 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
118
120
  { status: 409 },
119
121
  )
120
122
  }
123
+ // The poll worker returns immediately for a channel whose adapter declares
124
+ // real-time push, so enqueueing a job here would answer 202 for work that can
125
+ // never run and the UI would promise messages that never arrive (#4980).
126
+ if (!isHubPolledChannel(channel.capabilities)) {
127
+ // The page flashes this string verbatim, so it is operator-facing. Localize
128
+ // it when a request locale is resolvable and fall back to English rather
129
+ // than failing the request if the i18n registry is uninitialized — the same
130
+ // defensive shape the module's command interceptors use.
131
+ const fallback =
132
+ 'Channel is push-driven — polling does not apply. Inbound messages arrive through the provider push connection.'
133
+ let message = fallback
134
+ try {
135
+ const { translate } = await resolveTranslations()
136
+ message = translate('communication_channels.errors.pollNowPushDriven', fallback)
137
+ } catch {
138
+ message = fallback
139
+ }
140
+ return NextResponse.json({ error: message }, { status: 409 })
141
+ }
121
142
 
122
143
  const guard = await validateRouteMutationGuard({
123
144
  container,
@@ -166,7 +187,7 @@ export const openApi = {
166
187
  { status: 400, description: 'Invalid channel id' },
167
188
  { status: 401, description: 'Unauthorized' },
168
189
  { status: 404, description: 'Channel not found' },
169
- { status: 409, description: 'Channel disabled or not connected' },
190
+ { status: 409, description: 'Channel disabled, not connected, or push-driven (never polled)' },
170
191
  ],
171
192
  },
172
193
  },
@@ -42,6 +42,13 @@ type ChannelRow = {
42
42
  /** Spec C — push delivery state (null when provider doesn't support push). */
43
43
  pushStatus: 'active' | 'inactive' | 'failed' | null
44
44
  lastPushError: { code: string | null; message: string | null; at: string | null } | null
45
+ /**
46
+ * `true` when the adapter declares real-time push, so the hub's poll worker
47
+ * skips this channel entirely — inbound arrives over the provider connection.
48
+ */
49
+ supportsRealtimePush: boolean
50
+ /** `true` when the adapter implements `registerPush` (Gmail-style subscriptions). */
51
+ supportsPushRegistration: boolean
45
52
  createdAt: string | null
46
53
  }
47
54
 
@@ -314,17 +321,38 @@ export default function ProfileCommunicationChannelsPage() {
314
321
  },
315
322
  {
316
323
  id: 'pushStatus',
317
- header: t('communication_channels.push.status.active', 'Push'),
324
+ // Its own key: sharing `push.status.active` made the header render
325
+ // "Push active" over a column whose rows say "Polling only" (#4980).
326
+ header: t('communication_channels.profile.columns.push', 'Push'),
318
327
  cell: ({ row }) => {
319
- const supportsPush = row.original.providerKey === 'gmail'
320
- if (!supportsPush) {
328
+ const ps = row.original.pushStatus
329
+ const errorTitle = row.original.lastPushError?.message ?? undefined
330
+ // Derived from the adapter's declared capabilities, never from the
331
+ // provider name: `supportsPushRegistration` means push subscriptions
332
+ // can be (re-)registered from here, `supportsRealtimePush` means the
333
+ // hub's poll worker skips the channel because the provider connection
334
+ // delivers inbound itself (#4980).
335
+ if (!row.original.supportsPushRegistration) {
336
+ if (!row.original.supportsRealtimePush) {
337
+ return (
338
+ <span className="text-xs text-muted-foreground">
339
+ {t('communication_channels.push.status.inactive', 'Polling only')}
340
+ </span>
341
+ )
342
+ }
343
+ if (ps === 'failed') {
344
+ return (
345
+ <Tag variant="error" dot title={errorTitle}>
346
+ {t('communication_channels.push.status.pushDrivenFailed', 'Push connection failed')}
347
+ </Tag>
348
+ )
349
+ }
321
350
  return (
322
- <span className="text-xs text-muted-foreground">
323
- {t('communication_channels.push.status.inactive', 'Polling only')}
324
- </span>
351
+ <Tag variant="success" dot>
352
+ {t('communication_channels.push.status.pushDriven', 'Push-driven')}
353
+ </Tag>
325
354
  )
326
355
  }
327
- const ps = row.original.pushStatus
328
356
  if (ps === 'active') {
329
357
  return (
330
358
  <Tag variant="success" dot>
@@ -333,7 +361,6 @@ export default function ProfileCommunicationChannelsPage() {
333
361
  )
334
362
  }
335
363
  if (ps === 'failed') {
336
- const errorMsg = row.original.lastPushError?.message ?? null
337
364
  return (
338
365
  <div className="flex items-center gap-2">
339
366
  <Tag variant="error" dot>
@@ -345,18 +372,23 @@ export default function ProfileCommunicationChannelsPage() {
345
372
  size="sm"
346
373
  onClick={() => void onRegisterPush(row.original.id)}
347
374
  aria-label={t('communication_channels.push.button.reregister', 'Re-register push')}
348
- title={errorMsg ?? undefined}
375
+ title={errorTitle}
349
376
  >
350
377
  {t('communication_channels.push.button.reregister', 'Re-register push')}
351
378
  </Button>
352
379
  </div>
353
380
  )
354
381
  }
355
- // null or 'inactive' — provider supports push but not registered yet.
382
+ // null or 'inactive' — the provider can register push but has not yet.
383
+ // Only a hub-polled channel falls back to polling meanwhile; for a
384
+ // push-driven one nothing is delivering inbound at all, so claiming
385
+ // "Polling only" would repeat the defect this issue is about (#4980).
356
386
  return (
357
387
  <div className="flex items-center gap-2">
358
388
  <span className="text-xs text-muted-foreground">
359
- {t('communication_channels.push.status.inactive', 'Polling only')}
389
+ {row.original.supportsRealtimePush
390
+ ? t('communication_channels.push.status.notRegistered', 'Push not registered')
391
+ : t('communication_channels.push.status.inactive', 'Polling only')}
360
392
  </span>
361
393
  <Button
362
394
  type="button"
@@ -409,25 +441,38 @@ export default function ProfileCommunicationChannelsPage() {
409
441
  // Allowed from 'connected' AND 'error' — the latter lets the user
410
442
  // recover a stuck channel without disconnecting + reconnecting.
411
443
  // 'requires_reauth' and 'disconnected' are owned by other flows.
444
+ // A push-driven channel is never polled by the worker, so offering the
445
+ // action at all would promise a sync that cannot happen (#4980).
446
+ const pushDriven = row.original.supportsRealtimePush
412
447
  const pollable =
413
448
  row.original.isActive &&
449
+ !pushDriven &&
414
450
  (row.original.status === 'connected' || row.original.status === 'error')
415
451
  const label =
416
452
  row.original.status === 'error'
417
453
  ? t('communication_channels.profile.actions.retryPoll', 'Retry')
418
454
  : t('communication_channels.profile.actions.pollNow', 'Poll now')
419
- return (
455
+ const disabledReason = pushDriven
456
+ ? t(
457
+ 'communication_channels.profile.actions.pollNowPushDriven',
458
+ 'This channel is push-driven — inbound messages arrive over the provider connection, so polling does not apply.',
459
+ )
460
+ : undefined
461
+ const button = (
420
462
  <Button
421
463
  type="button"
422
464
  variant={row.original.status === 'error' ? 'default' : 'outline'}
423
465
  size="sm"
424
466
  onClick={() => void onPollNow(row.original.id)}
425
467
  disabled={!pollable}
426
- aria-label={label}
468
+ aria-label={disabledReason ? `${label} — ${disabledReason}` : label}
427
469
  >
428
470
  {label}
429
471
  </Button>
430
472
  )
473
+ // A disabled button does not receive hover events in every browser, so
474
+ // the explanation lives on a wrapper the pointer can still reach.
475
+ return disabledReason ? <span title={disabledReason}>{button}</span> : button
431
476
  },
432
477
  },
433
478
  {