{"version":3,"file":"dispatcher.mjs","names":[],"sources":["../../../../../../../notifications/src/dispatch/dispatcher.ts"],"sourcesContent":["/**\n * Internal dispatch core — shared by `defineNotification` and `notify.<channel>`.\n *\n * Responsibilities:\n * 1. Look up the channel in the configured registry (throws if missing).\n * 2. Run the rate-limit gate (if a notification type is known) — drop with a\n *    `skipped` event when the limiter refuses.\n * 3. Resolve the route: raw string > channel.route(notifiable) > { id }.\n * 4. Inject the per-send `idempotencyKey` into the database payload.\n * 5. Dispatch (sync via channel.send OR async via queue.dispatch).\n * 6. Emit `sent` on success, `failed` on throw.\n *\n * Per-channel failure isolation is the CALLER's job (it wraps a list of\n * `dispatchChannel` calls in `Promise.allSettled`).\n */\nimport { getNotificationConfig } from \"../config\";\nimport { ChannelNotFoundError, NoQueueDispatcherError, UnresolvableRouteError } from \"../errors\";\nimport type { ChannelName, Id, Notifiable, SendOptions } from \"../types\";\nimport { newDispatchId } from \"./dispatch-id\";\nimport { emit } from \"./notifications-event-bus\";\n\nexport type DispatchMode = \"send\" | \"queue\";\n\nexport type DispatchChannelArgs = {\n  channelName: string;\n  payload: unknown;\n  /** The recipient model — undefined when dispatching to a raw route string. */\n  to: Notifiable | undefined;\n  /** Raw route override (ad-hoc `notify.mail(\"x@y.com\", …)`). */\n  rawRoute?: string;\n  /** Notification type — drives the rate-limit gate. May be undefined. */\n  notificationType: string | undefined;\n  mode: DispatchMode;\n  options: SendOptions;\n};\n\n/**\n * Dispatch ONE rendered payload to ONE recipient through ONE channel.\n * Does NOT consult `PreferenceProvider` — that gate runs at the caller level.\n *\n * Throws `ChannelNotFoundError` (channel not registered) and\n * `UnresolvableRouteError` (channel's `route()` returned undefined) — both are\n * configuration/data errors that should surface loudly. Transport failures\n * (channel.send throwing) are emitted as `failed` AND rethrown.\n */\nexport async function dispatchChannel(args: DispatchChannelArgs): Promise<void> {\n  const config = getNotificationConfig();\n  const channel = config.channels[args.channelName as ChannelName];\n\n  if (!channel) {\n    throw new ChannelNotFoundError(args.channelName);\n  }\n\n  // One id for this (channel, recipient) dispatch — `sending` and its terminal\n  // `sent` / `failed` share it so observers can pair them.\n  const dispatchId = newDispatchId();\n\n  // Rate-limit gate. Only applies when we have a notifiable + a notification\n  // type (otherwise there's nothing to rate-limit on). `force` does NOT\n  // bypass — rate limits are a safety valve, not a UX preference.\n  //\n  // NOTE (Phase-2 seam): for mode \"queue\" the budget is consumed at ENQUEUE\n  // time here, not at delivery. When the herald worker lands it should run\n  // this gate at delivery instead; see RateLimiter docstring.\n  if (args.to && args.notificationType && config.rateLimit) {\n    const allowed = await config.rateLimit.allow(\n      args.to,\n      args.channelName as ChannelName,\n      args.notificationType,\n    );\n\n    if (!allowed) {\n      await emit(\"skipped\", {\n        dispatchId,\n        channel: args.channelName,\n        notifiable: args.to,\n        reason: \"rate-limit\",\n        options: args.options,\n      });\n      return;\n    }\n  }\n\n  const route = resolveRoute(args, channel.route);\n\n  // Inject idempotency key into the database payload (so the repo can dedupe).\n  let payload = args.payload;\n  if (\n    args.channelName === \"database\" &&\n    args.options.idempotencyKey &&\n    payload &&\n    typeof payload === \"object\"\n  ) {\n    payload = { ...(payload as object), idempotencyKey: args.options.idempotencyKey };\n  }\n\n  // `sending` fires after the gates pass + the route resolves, BEFORE the\n  // transport — awaited so it always precedes the send. Observers can't abort\n  // the send, but a slow handler delays it; keep them fast.\n  await emit(\"sending\", {\n    dispatchId,\n    channel: args.channelName,\n    notifiable: args.to,\n    payload,\n    options: args.options,\n  });\n\n  const startedAt = Date.now();\n\n  try {\n    if (args.mode === \"queue\") {\n      if (!config.queue) {\n        throw new NoQueueDispatcherError(args.channelName);\n      }\n      await config.queue.dispatch({\n        channel: args.channelName,\n        route,\n        payload,\n        options: args.options,\n      });\n    } else {\n      await channel.send({\n        payload: payload as never,\n        route,\n        notifiable: args.to,\n        options: args.options,\n      });\n    }\n\n    await emit(\"sent\", {\n      dispatchId,\n      channel: args.channelName,\n      notifiable: args.to,\n      payload,\n      options: args.options,\n      durationMs: Date.now() - startedAt,\n    });\n  } catch (error) {\n    await emit(\"failed\", {\n      dispatchId,\n      channel: args.channelName,\n      notifiable: args.to,\n      payload,\n      error: error as Error,\n      options: args.options,\n      durationMs: Date.now() - startedAt,\n    });\n    throw error;\n  }\n}\n\n/**\n * Route resolution priority:\n *   1. raw route string (ad-hoc `notify.mail(\"x@y.com\", …)`)\n *   2. the channel's `route(notifiable)` resolver\n *   3. `{ id: notifiable.id }` — ONLY when the channel declares no resolver\n *\n * When a channel HAS a resolver but it returns undefined (e.g. a mail\n * recipient with no email), we throw `UnresolvableRouteError` rather than\n * silently coercing to `{ id }` — that would hand a string-route channel an\n * object and fail deep inside the transport.\n */\nfunction resolveRoute(\n  args: DispatchChannelArgs,\n  resolver: ((notifiable: Notifiable) => string | { id: Id } | undefined) | undefined,\n): string | { id: Id } {\n  if (args.rawRoute !== undefined) {\n    return args.rawRoute;\n  }\n\n  if (!args.to) {\n    throw new UnresolvableRouteError(args.channelName);\n  }\n\n  // No resolver declared → the channel addresses by id (database/internal).\n  if (!resolver) {\n    return { id: args.to.id };\n  }\n\n  const resolved = resolver(args.to);\n\n  if (resolved === undefined) {\n    throw new UnresolvableRouteError(args.channelName, args.to.id);\n  }\n\n  return resolved;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6CA,eAAsB,gBAAgB,MAA0C;CAC9E,MAAM,SAAS,sBAAsB;CACrC,MAAM,UAAU,OAAO,SAAS,KAAK;CAErC,IAAI,CAAC,SACH,MAAM,IAAI,qBAAqB,KAAK,WAAW;CAKjD,MAAM,aAAa,cAAc;CASjC,IAAI,KAAK,MAAM,KAAK,oBAAoB,OAAO,WAO7C;MAAI,CAAC,MANiB,OAAO,UAAU,MACrC,KAAK,IACL,KAAK,aACL,KAAK,gBACP,GAEc;GACZ,MAAM,KAAK,WAAW;IACpB;IACA,SAAS,KAAK;IACd,YAAY,KAAK;IACjB,QAAQ;IACR,SAAS,KAAK;GAChB,CAAC;GACD;EACF;;CAGF,MAAM,QAAQ,aAAa,MAAM,QAAQ,KAAK;CAG9C,IAAI,UAAU,KAAK;CACnB,IACE,KAAK,gBAAgB,cACrB,KAAK,QAAQ,kBACb,WACA,OAAO,YAAY,UAEnB,UAAU;EAAE,GAAI;EAAoB,gBAAgB,KAAK,QAAQ;CAAe;CAMlF,MAAM,KAAK,WAAW;EACpB;EACA,SAAS,KAAK;EACd,YAAY,KAAK;EACjB;EACA,SAAS,KAAK;CAChB,CAAC;CAED,MAAM,YAAY,KAAK,IAAI;CAE3B,IAAI;EACF,IAAI,KAAK,SAAS,SAAS;GACzB,IAAI,CAAC,OAAO,OACV,MAAM,IAAI,uBAAuB,KAAK,WAAW;GAEnD,MAAM,OAAO,MAAM,SAAS;IAC1B,SAAS,KAAK;IACd;IACA;IACA,SAAS,KAAK;GAChB,CAAC;EACH,OACE,MAAM,QAAQ,KAAK;GACR;GACT;GACA,YAAY,KAAK;GACjB,SAAS,KAAK;EAChB,CAAC;EAGH,MAAM,KAAK,QAAQ;GACjB;GACA,SAAS,KAAK;GACd,YAAY,KAAK;GACjB;GACA,SAAS,KAAK;GACd,YAAY,KAAK,IAAI,IAAI;EAC3B,CAAC;CACH,SAAS,OAAO;EACd,MAAM,KAAK,UAAU;GACnB;GACA,SAAS,KAAK;GACd,YAAY,KAAK;GACjB;GACO;GACP,SAAS,KAAK;GACd,YAAY,KAAK,IAAI,IAAI;EAC3B,CAAC;EACD,MAAM;CACR;AACF;;;;;;;;;;;;AAaA,SAAS,aACP,MACA,UACqB;CACrB,IAAI,KAAK,aAAa,QACpB,OAAO,KAAK;CAGd,IAAI,CAAC,KAAK,IACR,MAAM,IAAI,uBAAuB,KAAK,WAAW;CAInD,IAAI,CAAC,UACH,OAAO,EAAE,IAAI,KAAK,GAAG,GAAG;CAG1B,MAAM,WAAW,SAAS,KAAK,EAAE;CAEjC,IAAI,aAAa,QACf,MAAM,IAAI,uBAAuB,KAAK,aAAa,KAAK,GAAG,EAAE;CAG/D,OAAO;AACT"}