@facteurjs/core 1.0.0-beta.3 → 2.0.0-beta.0

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 (189) hide show
  1. package/README.md +27 -0
  2. package/dist/api/handlers/{notifications.js → notifications.mjs} +39 -16
  3. package/dist/api/handlers/preferences.mjs +73 -0
  4. package/dist/api/handlers/utils.mjs +23 -0
  5. package/dist/api/types.d.mts +52 -0
  6. package/dist/api/types.mjs +1 -0
  7. package/dist/api.d.mts +22 -0
  8. package/dist/api.mjs +25 -0
  9. package/dist/channels/aws-sns/{channel.d.ts → channel.d.mts} +3 -3
  10. package/dist/channels/aws-sns/{channel.js → channel.mjs} +2 -3
  11. package/dist/channels/aws-sns/types.mjs +1 -0
  12. package/dist/channels/aws-sns.d.mts +3 -0
  13. package/dist/channels/aws-sns.mjs +4 -0
  14. package/dist/channels/discord/{channel.d.ts → channel.d.mts} +5 -5
  15. package/dist/channels/discord/{channel.js → channel.mjs} +1 -1
  16. package/dist/channels/discord/{message.d.ts → message.d.mts} +2 -2
  17. package/dist/channels/discord/{message.js → message.mjs} +1 -1
  18. package/dist/channels/discord/{types.d.ts → types.d.mts} +1 -1
  19. package/dist/channels/discord/types.mjs +1 -0
  20. package/dist/channels/discord.d.mts +3 -0
  21. package/dist/channels/discord.mjs +4 -0
  22. package/dist/channels/expo/{channel.d.ts → channel.d.mts} +5 -3
  23. package/dist/channels/expo/channel.mjs +69 -0
  24. package/dist/channels/expo/types.mjs +1 -0
  25. package/dist/channels/expo.d.mts +3 -0
  26. package/dist/channels/expo.mjs +4 -0
  27. package/dist/channels/fcm/{channel.d.ts → channel.d.mts} +5 -5
  28. package/dist/channels/fcm/channel.mjs +68 -0
  29. package/dist/channels/fcm/types.mjs +1 -0
  30. package/dist/channels/fcm.d.mts +3 -0
  31. package/dist/channels/fcm.mjs +4 -0
  32. package/dist/channels/slack/{channel.d.ts → channel.d.mts} +5 -5
  33. package/dist/channels/slack/{channel.js → channel.mjs} +2 -2
  34. package/dist/channels/slack/{message.d.ts → message.d.mts} +1 -1
  35. package/dist/channels/slack/{message.js → message.mjs} +1 -1
  36. package/dist/channels/slack/{types.d.ts → types.d.mts} +1 -1
  37. package/dist/channels/slack/types.mjs +1 -0
  38. package/dist/channels/slack.d.mts +3 -0
  39. package/dist/channels/slack.mjs +4 -0
  40. package/dist/channels/socketio/{channel.d.ts → channel.d.mts} +3 -3
  41. package/dist/channels/socketio/{channel.js → channel.mjs} +2 -3
  42. package/dist/channels/socketio/types.mjs +1 -0
  43. package/dist/channels/socketio.d.mts +3 -0
  44. package/dist/channels/socketio.mjs +4 -0
  45. package/dist/channels/transmit/{channel.d.ts → channel.d.mts} +3 -3
  46. package/dist/channels/transmit/{channel.js → channel.mjs} +2 -3
  47. package/dist/channels/transmit/types.mjs +1 -0
  48. package/dist/channels/transmit.d.mts +3 -0
  49. package/dist/channels/transmit.mjs +4 -0
  50. package/dist/channels/twilio/{channel.d.ts → channel.d.mts} +3 -3
  51. package/dist/channels/twilio/{channel.js → channel.mjs} +3 -6
  52. package/dist/channels/twilio/types.mjs +1 -0
  53. package/dist/channels/twilio.d.mts +4 -0
  54. package/dist/channels/twilio.mjs +4 -0
  55. package/dist/channels/webhook/{exceptions.d.ts → exceptions.d.mts} +1 -1
  56. package/dist/channels/webhook/{provider.d.ts → provider.d.mts} +3 -3
  57. package/dist/channels/webhook/{provider.js → provider.mjs} +6 -8
  58. package/dist/channels/webhook/types.mjs +1 -0
  59. package/dist/channels/webhook.d.mts +4 -0
  60. package/dist/channels/webhook.mjs +5 -0
  61. package/dist/channels/webpush/{channel.d.ts → channel.d.mts} +3 -3
  62. package/dist/channels/webpush/{channel.js → channel.mjs} +2 -3
  63. package/dist/channels/webpush/types.mjs +1 -0
  64. package/dist/channels/webpush.d.mts +3 -0
  65. package/dist/channels/webpush.mjs +4 -0
  66. package/dist/database/adapters/{knex.d.ts → knex.d.mts} +1 -1
  67. package/dist/database/adapters/{knex.js → knex.mjs} +19 -15
  68. package/dist/database/adapters/{kysely.d.ts → kysely.d.mts} +1 -1
  69. package/dist/database/adapters/{kysely.js → kysely.mjs} +2 -4
  70. package/dist/database/{channel.d.ts → channel.d.mts} +3 -3
  71. package/dist/database/{channel.js → channel.mjs} +4 -6
  72. package/dist/database/{database.d.ts → database.d.mts} +3 -3
  73. package/dist/database/{database.js → database.mjs} +15 -12
  74. package/dist/database/{message.d.ts → message.d.mts} +2 -2
  75. package/dist/database/{types.d.ts → types.d.mts} +3 -3
  76. package/dist/database/types.mjs +1 -0
  77. package/dist/database.d.mts +3 -0
  78. package/dist/database.mjs +4 -0
  79. package/dist/errors/{duplicate_notification_exception.js → duplicate_notification_exception.mjs} +1 -2
  80. package/dist/errors/{http_error.d.ts → http_error.d.mts} +0 -3
  81. package/dist/errors/{index.d.ts → index.d.mts} +1 -1
  82. package/dist/errors/{index.js → index.mjs} +2 -3
  83. package/dist/events/{events.d.ts → events.d.mts} +2 -2
  84. package/dist/facteur.d.mts +45 -0
  85. package/dist/facteur.mjs +167 -0
  86. package/dist/{fake.d.ts → fake.d.mts} +3 -3
  87. package/dist/{fake.js → fake.mjs} +2 -3
  88. package/dist/{index.d.ts → index.d.mts} +3 -3
  89. package/dist/{index.js → index.mjs} +2 -2
  90. package/dist/notifications/batching_sender.mjs +148 -0
  91. package/dist/notifications/{channel_resolver.js → channel_resolver.mjs} +40 -31
  92. package/dist/notifications/notification_builder.mjs +77 -0
  93. package/dist/notifications/{notification_discoverer.d.ts → notification_discoverer.d.mts} +1 -1
  94. package/dist/notifications/{notification_discoverer.js → notification_discoverer.mjs} +9 -14
  95. package/dist/notifications/notification_sender.mjs +356 -0
  96. package/dist/notifications/orchestration_sender.mjs +92 -0
  97. package/dist/{options.d.ts → options.d.mts} +7 -6
  98. package/dist/{options.js → options.mjs} +5 -4
  99. package/dist/types/builder.d.mts +122 -0
  100. package/dist/types/channel.d.mts +56 -0
  101. package/dist/types/{events.d.ts → events.d.mts} +1 -1
  102. package/dist/types/{extend.d.ts → extend.d.mts} +2 -2
  103. package/dist/types/{notifications.d.ts → notifications.d.mts} +3 -3
  104. package/dist/types/{options.d.ts → options.d.mts} +94 -11
  105. package/dist/types/{preferences.d.ts → preferences.d.mts} +1 -1
  106. package/dist/types.d.mts +9 -0
  107. package/dist/types.mjs +4 -0
  108. package/dist/utils/chunk.mjs +28 -0
  109. package/package.json +69 -54
  110. package/dist/api/handlers/preferences.js +0 -43
  111. package/dist/api/index.d.ts +0 -16
  112. package/dist/api/index.js +0 -21
  113. package/dist/api/types.d.ts +0 -22
  114. package/dist/api/types.js +0 -0
  115. package/dist/channels/aws-sns/index.d.ts +0 -3
  116. package/dist/channels/aws-sns/index.js +0 -4
  117. package/dist/channels/aws-sns/types.js +0 -0
  118. package/dist/channels/discord/index.d.ts +0 -3
  119. package/dist/channels/discord/index.js +0 -4
  120. package/dist/channels/discord/types.js +0 -0
  121. package/dist/channels/expo/channel.js +0 -38
  122. package/dist/channels/expo/index.d.ts +0 -3
  123. package/dist/channels/expo/index.js +0 -4
  124. package/dist/channels/expo/types.js +0 -0
  125. package/dist/channels/fcm/channel.js +0 -44
  126. package/dist/channels/fcm/index.d.ts +0 -3
  127. package/dist/channels/fcm/index.js +0 -4
  128. package/dist/channels/fcm/types.js +0 -0
  129. package/dist/channels/slack/index.d.ts +0 -3
  130. package/dist/channels/slack/index.js +0 -4
  131. package/dist/channels/slack/types.js +0 -0
  132. package/dist/channels/socketio/index.d.ts +0 -3
  133. package/dist/channels/socketio/index.js +0 -4
  134. package/dist/channels/socketio/types.js +0 -0
  135. package/dist/channels/transmit/index.d.ts +0 -3
  136. package/dist/channels/transmit/index.js +0 -4
  137. package/dist/channels/transmit/types.js +0 -0
  138. package/dist/channels/twilio/index.d.ts +0 -4
  139. package/dist/channels/twilio/index.js +0 -4
  140. package/dist/channels/twilio/types.js +0 -0
  141. package/dist/channels/webhook/index.d.ts +0 -4
  142. package/dist/channels/webhook/index.js +0 -5
  143. package/dist/channels/webhook/types.js +0 -0
  144. package/dist/channels/webpush/index.d.ts +0 -3
  145. package/dist/channels/webpush/index.js +0 -4
  146. package/dist/channels/webpush/types.js +0 -0
  147. package/dist/database/index.d.ts +0 -3
  148. package/dist/database/index.js +0 -4
  149. package/dist/database/types.js +0 -0
  150. package/dist/facteur.d.ts +0 -37
  151. package/dist/facteur.js +0 -100
  152. package/dist/notifications/notification_sender.js +0 -211
  153. package/dist/types/channel.d.ts +0 -18
  154. package/dist/types/index.d.ts +0 -8
  155. package/dist/types/index.js +0 -4
  156. /package/dist/channels/aws-sns/{message.d.ts → message.d.mts} +0 -0
  157. /package/dist/channels/aws-sns/{message.js → message.mjs} +0 -0
  158. /package/dist/channels/aws-sns/{types.d.ts → types.d.mts} +0 -0
  159. /package/dist/channels/expo/{message.d.ts → message.d.mts} +0 -0
  160. /package/dist/channels/expo/{message.js → message.mjs} +0 -0
  161. /package/dist/channels/expo/{types.d.ts → types.d.mts} +0 -0
  162. /package/dist/channels/fcm/{message.d.ts → message.d.mts} +0 -0
  163. /package/dist/channels/fcm/{message.js → message.mjs} +0 -0
  164. /package/dist/channels/fcm/{types.d.ts → types.d.mts} +0 -0
  165. /package/dist/channels/socketio/{message.d.ts → message.d.mts} +0 -0
  166. /package/dist/channels/socketio/{message.js → message.mjs} +0 -0
  167. /package/dist/channels/socketio/{types.d.ts → types.d.mts} +0 -0
  168. /package/dist/channels/transmit/{message.d.ts → message.d.mts} +0 -0
  169. /package/dist/channels/transmit/{message.js → message.mjs} +0 -0
  170. /package/dist/channels/transmit/{types.d.ts → types.d.mts} +0 -0
  171. /package/dist/channels/twilio/{message.d.ts → message.d.mts} +0 -0
  172. /package/dist/channels/twilio/{message.js → message.mjs} +0 -0
  173. /package/dist/channels/twilio/{types.d.ts → types.d.mts} +0 -0
  174. /package/dist/channels/webhook/{exceptions.js → exceptions.mjs} +0 -0
  175. /package/dist/channels/webhook/{message.d.ts → message.d.mts} +0 -0
  176. /package/dist/channels/webhook/{message.js → message.mjs} +0 -0
  177. /package/dist/channels/webhook/{types.d.ts → types.d.mts} +0 -0
  178. /package/dist/channels/webpush/{message.d.ts → message.d.mts} +0 -0
  179. /package/dist/channels/webpush/{message.js → message.mjs} +0 -0
  180. /package/dist/channels/webpush/{types.d.ts → types.d.mts} +0 -0
  181. /package/dist/database/{message.js → message.mjs} +0 -0
  182. /package/dist/{debug.js → debug.mjs} +0 -0
  183. /package/dist/errors/{duplicate_notification_exception.d.ts → duplicate_notification_exception.d.mts} +0 -0
  184. /package/dist/errors/{http_error.js → http_error.mjs} +0 -0
  185. /package/dist/events/{events.js → events.mjs} +0 -0
  186. /package/dist/{helpers.js → helpers.mjs} +0 -0
  187. /package/dist/types/{channel.js → channel.mjs} +0 -0
  188. /package/dist/types/{notifications.js → notifications.mjs} +0 -0
  189. /package/dist/types/{queue.d.ts → queue.d.mts} +0 -0
@@ -0,0 +1,356 @@
1
+ import { errors } from "../errors/index.mjs";
2
+ import { chunk } from "../utils/chunk.mjs";
3
+ import "./channel_resolver.mjs";
4
+ import { capitalizeFirstLetter } from "../helpers.mjs";
5
+ import { facteurEvents } from "../events/events.mjs";
6
+ import debug_default from "../debug.mjs";
7
+ import { Tenace, backoff } from "@julr/tenace";
8
+
9
+ //#region src/notifications/notification_sender.ts
10
+ /**
11
+ * Responsible for sending notifications and messages
12
+ */
13
+ var NotificationSender = class {
14
+ constructor(channels, channelResolver, emitter, retryConfig = {}) {
15
+ this.channels = channels;
16
+ this.channelResolver = channelResolver;
17
+ this.emitter = emitter;
18
+ this.retryConfig = retryConfig;
19
+ }
20
+ /**
21
+ * Build the message content by calling the notification's `as<ChannelName>Message` method
22
+ */
23
+ #buildMessageContent(options) {
24
+ const { notification, channelName, sendOptions } = options;
25
+ const capitalizedChannelName = capitalizeFirstLetter(channelName);
26
+ const messageBuilder = notification[`as${capitalizedChannelName}Message`];
27
+ if (typeof messageBuilder !== "function") throw new errors.E_MISSING_MESSAGE_METHOD([capitalizedChannelName]);
28
+ const content = messageBuilder.call(notification, {
29
+ to: sendOptions.to,
30
+ params: sendOptions.params,
31
+ tenantId: sendOptions.tenantId
32
+ });
33
+ if (!content) return null;
34
+ return {
35
+ content,
36
+ capitalizedChannelName
37
+ };
38
+ }
39
+ /**
40
+ * Execute a function with retry/timeout if configured
41
+ */
42
+ async #executeWithRetry(fn, options) {
43
+ if (!(options.retries !== void 0 || options.timeout !== void 0)) return fn();
44
+ let builder = Tenace.call(fn);
45
+ if (options.timeout !== void 0) builder = builder.withTimeout(options.timeout, "aggressive");
46
+ if (options.retries !== void 0 && options.retries > 0) builder = builder.withRetry({
47
+ times: options.retries,
48
+ delay: backoff.exponentialWithJitter({
49
+ initial: 100,
50
+ max: 5e3
51
+ })
52
+ });
53
+ await builder.execute();
54
+ }
55
+ /**
56
+ * Emit notification sending event
57
+ */
58
+ #emitNotificationSending(notification, resolvedChannels) {
59
+ const event = facteurEvents.notificationSending({
60
+ notification,
61
+ resolvedChannels
62
+ });
63
+ this.emitter.emit(event.name, event.data);
64
+ }
65
+ /**
66
+ * Emit notification sent event
67
+ */
68
+ #emitNotificationSent(notification, results) {
69
+ const event = facteurEvents.notificationSent({
70
+ notification,
71
+ results
72
+ });
73
+ this.emitter.emit(event.name, event.data);
74
+ }
75
+ /**
76
+ * Emit notification failed event
77
+ */
78
+ #emitNotificationFailed(notification, errors$1) {
79
+ const event = facteurEvents.notificationFailed({
80
+ notification,
81
+ errors: errors$1
82
+ });
83
+ this.emitter.emit(event.name, event.data);
84
+ }
85
+ /**
86
+ * Emit message sending event
87
+ */
88
+ #emitMessageSending(notification, channelName, message) {
89
+ const event = facteurEvents.messageSending({
90
+ notification,
91
+ channelName,
92
+ message
93
+ });
94
+ this.emitter.emit(event.name, event.data);
95
+ }
96
+ /**
97
+ * Emit message sent event
98
+ */
99
+ #emitMessageSent(notification, channelName, message) {
100
+ const event = facteurEvents.messageSent({
101
+ notification,
102
+ channelName,
103
+ message
104
+ });
105
+ this.emitter.emit(event.name, event.data);
106
+ }
107
+ /**
108
+ * Emit message failed event
109
+ */
110
+ #emitMessageFailed(notification, channelName, message, error) {
111
+ const event = facteurEvents.messageFailed({
112
+ notification,
113
+ channelName,
114
+ message,
115
+ error
116
+ });
117
+ this.emitter.emit(event.name, event.data);
118
+ }
119
+ /**
120
+ * Get a channel by its name, throws if not registered
121
+ */
122
+ #getChannel(channelName) {
123
+ const channel = this.channels[channelName];
124
+ if (!channel) throw new Error(`Channel '${channelName}' is not registered`);
125
+ return channel;
126
+ }
127
+ /**
128
+ * Resolve retry options for a specific channel.
129
+ * Priority: send options > channel config > global config
130
+ */
131
+ #resolveRetryOptions(channelName, sendOptions) {
132
+ const globalConfig = this.retryConfig;
133
+ const channelConfig = globalConfig.channels?.[channelName];
134
+ const retries = sendOptions.retries ?? channelConfig?.retries ?? globalConfig.retries;
135
+ const timeout = sendOptions.timeout ?? channelConfig?.timeout ?? globalConfig.timeout;
136
+ return {
137
+ ...retries !== void 0 && { retries },
138
+ ...timeout !== void 0 && { timeout }
139
+ };
140
+ }
141
+ /**
142
+ * Send a single message through a specific channel
143
+ */
144
+ async #sendMessage(options) {
145
+ const { notification, channelName, options: sendOptions, channelConfig } = options;
146
+ const channel = this.#getChannel(channelName);
147
+ const messageResult = this.#buildMessageContent({
148
+ notification,
149
+ channelName,
150
+ sendOptions
151
+ });
152
+ if (!messageResult) return null;
153
+ const { content: messageContent } = messageResult;
154
+ debug_default(`Sending message via ${channelName}: %O`, messageContent);
155
+ this.#emitMessageSending(notification, channelName, messageContent);
156
+ const retryOptions = this.#resolveRetryOptions(channelName, sendOptions);
157
+ const doSend = async () => {
158
+ await channel.send({
159
+ tenantId: sendOptions.tenantId,
160
+ message: messageContent,
161
+ targets: channelConfig.target,
162
+ to: sendOptions.to
163
+ });
164
+ };
165
+ try {
166
+ await this.#executeWithRetry(doSend, retryOptions);
167
+ debug_default(`Message sent via ${channelName}`);
168
+ this.#emitMessageSent(notification, channelName, messageContent);
169
+ return {
170
+ channel: channelName,
171
+ status: "success"
172
+ };
173
+ } catch (error) {
174
+ this.#emitMessageFailed(notification, channelName, messageContent, error);
175
+ throw error;
176
+ }
177
+ }
178
+ /**
179
+ * Process results from sending messages and emit appropriate events
180
+ */
181
+ async #processResults(options) {
182
+ const { results, throwOnError, notification } = options;
183
+ const channelResults = results.filter((result) => result !== null);
184
+ const successes = channelResults.filter((r) => r.status === "success");
185
+ const failures = channelResults.filter((r) => r.status === "failed");
186
+ if (!failures.length) {
187
+ this.#emitNotificationSent(notification, channelResults);
188
+ await notification.afterSend();
189
+ return {
190
+ failed: failures.length,
191
+ success: successes.length,
192
+ results: channelResults
193
+ };
194
+ }
195
+ const failureReasons = failures.map((r) => r.error);
196
+ this.#emitNotificationFailed(notification, failureReasons);
197
+ await notification.afterSend();
198
+ if (throwOnError !== false) throw new errors.E_SEND_NOTIFICATION_FAILED(failureReasons);
199
+ return {
200
+ failed: failures.length,
201
+ success: successes.length,
202
+ results: channelResults
203
+ };
204
+ }
205
+ /**
206
+ * Send messages using the channel's batch API
207
+ */
208
+ async #sendWithBatchApi(channelName, channel, messages) {
209
+ const batches = chunk(messages, channel.batchConfig?.maxSize ?? 100);
210
+ const allResults = [];
211
+ for (const batch of batches) {
212
+ debug_default(`Sending batch of ${batch.length} messages via ${channelName}`);
213
+ for (const msg of batch) this.#emitMessageSending(msg.notification, channelName, msg.messageContent);
214
+ try {
215
+ const batchResult = await channel.sendBatch(batch.map((m) => m.sendParams));
216
+ for (const result of batchResult.results) {
217
+ const msg = batch[result.index];
218
+ if (result.status === "success") this.#emitMessageSent(msg.notification, channelName, msg.messageContent);
219
+ else this.#emitMessageFailed(msg.notification, channelName, msg.messageContent, result.error || /* @__PURE__ */ new Error("Unknown batch error"));
220
+ allResults.push({
221
+ channel: channelName,
222
+ status: result.status,
223
+ error: result.error
224
+ });
225
+ }
226
+ } catch (error) {
227
+ for (const msg of batch) {
228
+ this.#emitMessageFailed(msg.notification, channelName, msg.messageContent, error);
229
+ allResults.push({
230
+ channel: channelName,
231
+ status: "failed",
232
+ error
233
+ });
234
+ }
235
+ }
236
+ }
237
+ return allResults;
238
+ }
239
+ /**
240
+ * Send messages individually when batch API is not available
241
+ */
242
+ async #sendIndividually(channelName, channel, messages, sendOptions) {
243
+ const retryOptions = this.#resolveRetryOptions(channelName, sendOptions);
244
+ const results = [];
245
+ for (const msg of messages) {
246
+ debug_default(`Sending message via ${channelName}: %O`, msg.messageContent);
247
+ this.#emitMessageSending(msg.notification, channelName, msg.messageContent);
248
+ try {
249
+ await this.#executeWithRetry(() => channel.send(msg.sendParams), retryOptions);
250
+ debug_default(`Message sent via ${channelName}`);
251
+ this.#emitMessageSent(msg.notification, channelName, msg.messageContent);
252
+ results.push({
253
+ channel: channelName,
254
+ status: "success"
255
+ });
256
+ } catch (error) {
257
+ this.#emitMessageFailed(msg.notification, channelName, msg.messageContent, error);
258
+ results.push({
259
+ channel: channelName,
260
+ status: "failed",
261
+ error
262
+ });
263
+ }
264
+ }
265
+ return results;
266
+ }
267
+ /**
268
+ * Send a notification through all resolved channels
269
+ */
270
+ async send(options, notification) {
271
+ const { via, params, tenantId, to } = options;
272
+ const resolvedChannels = await this.channelResolver.resolveChannels({
273
+ to,
274
+ params,
275
+ tenantId,
276
+ notification: options.notification,
277
+ ...via ? { via } : {}
278
+ });
279
+ debug_default(`Resolved channels: %O`, resolvedChannels);
280
+ this.#emitNotificationSending(notification, resolvedChannels);
281
+ const promises = Object.entries(resolvedChannels).map(async ([name, config]) => {
282
+ if (!config.shouldSend || !config.target) return null;
283
+ return await this.#sendMessage({
284
+ notification,
285
+ channelConfig: config,
286
+ channelName: name,
287
+ options
288
+ }).catch((error) => {
289
+ debug_default(`Failed to send notification via ${name}: %O`, error);
290
+ return {
291
+ channel: name,
292
+ status: "failed",
293
+ error
294
+ };
295
+ });
296
+ });
297
+ return await this.#processResults({
298
+ notification,
299
+ results: await Promise.all(promises),
300
+ throwOnError: options.throwOnError !== false
301
+ });
302
+ }
303
+ /**
304
+ * Prepare a message for a specific channel without sending it.
305
+ * Returns null if the message should not be sent.
306
+ */
307
+ prepareMessage(options) {
308
+ const { notification, channelName, sendOptions, channelConfig } = options;
309
+ this.#getChannel(channelName);
310
+ const messageResult = this.#buildMessageContent({
311
+ notification,
312
+ channelName,
313
+ sendOptions
314
+ });
315
+ if (!messageResult) return null;
316
+ const { content: messageContent } = messageResult;
317
+ return {
318
+ channelName,
319
+ notification,
320
+ messageContent,
321
+ sendParams: {
322
+ tenantId: sendOptions.tenantId,
323
+ message: messageContent,
324
+ targets: channelConfig.target,
325
+ to: sendOptions.to
326
+ }
327
+ };
328
+ }
329
+ /**
330
+ * Send multiple messages through a channel using batch API if available.
331
+ * Falls back to individual sends if batch is not supported.
332
+ */
333
+ async sendChannelBatch(options) {
334
+ const { channelName, messages, sendOptions } = options;
335
+ const channel = this.#getChannel(channelName);
336
+ const disableDriverBatch = sendOptions.disableDriverBatch === true;
337
+ if (channel.sendBatch && channel.batchConfig?.enabled !== false && !disableDriverBatch) return this.#sendWithBatchApi(channelName, channel, messages);
338
+ return this.#sendIndividually(channelName, channel, messages, sendOptions);
339
+ }
340
+ /**
341
+ * Check if a channel supports batch sending
342
+ */
343
+ channelSupportsBatch(channelName) {
344
+ const ch = this.channels[channelName];
345
+ return !!(ch?.sendBatch && ch.batchConfig?.enabled !== false);
346
+ }
347
+ /**
348
+ * Get the batch config for a channel
349
+ */
350
+ getChannelBatchConfig(channelName) {
351
+ return this.channels[channelName]?.batchConfig;
352
+ }
353
+ };
354
+
355
+ //#endregion
356
+ export { NotificationSender };
@@ -0,0 +1,92 @@
1
+ import { chunk } from "../utils/chunk.mjs";
2
+ import { Tenace, backoff } from "@julr/tenace";
3
+
4
+ //#region src/notifications/orchestration_sender.ts
5
+ /**
6
+ * Handles bulk notification sending using orchestration mode.
7
+ *
8
+ * In orchestration mode, each recipient is processed individually with configurable
9
+ * concurrency, chunking, retries, and timeout. This provides fine-grained control
10
+ * over the sending process and per-recipient error handling.
11
+ */
12
+ var OrchestrationSender = class {
13
+ #sender;
14
+ constructor(sender) {
15
+ this.#sender = sender;
16
+ }
17
+ /**
18
+ * Sends notifications to multiple recipients with concurrency control
19
+ */
20
+ async send(options) {
21
+ const { chunkSize = Infinity, concurrency = 10, continueOnError = false, retries = 0, timeout, onProgress } = options.builderOptions;
22
+ const chunks = chunk(options.recipients, chunkSize);
23
+ const allResults = [];
24
+ let completedCount = 0;
25
+ const totalCount = options.recipients.length;
26
+ const { retries: _retries, timeout: _timeout, ...perRecipientOptions } = options.builderOptions;
27
+ for (const recipientChunk of chunks) {
28
+ const chunkResults = await this.#processChunk({
29
+ recipientChunk,
30
+ perRecipientOptions,
31
+ prepareNotification: options.prepareNotification,
32
+ concurrency,
33
+ retries,
34
+ continueOnError,
35
+ onProgress: () => {
36
+ completedCount++;
37
+ onProgress?.(completedCount, totalCount);
38
+ },
39
+ ...timeout && { timeout }
40
+ });
41
+ allResults.push(...chunkResults);
42
+ }
43
+ return this.#aggregateResults(allResults);
44
+ }
45
+ /**
46
+ * Processes a single chunk of recipients with Tenace for concurrency and retry
47
+ */
48
+ async #processChunk(options) {
49
+ const builder = Tenace.map(options.recipientChunk, async (recipient) => {
50
+ const recipientOptions = {
51
+ ...options.perRecipientOptions,
52
+ to: recipient
53
+ };
54
+ const { notification, shouldSkip } = await options.prepareNotification(recipient, recipientOptions);
55
+ if (shouldSkip) {
56
+ options.onProgress();
57
+ return {
58
+ success: 0,
59
+ failed: 0,
60
+ results: []
61
+ };
62
+ }
63
+ const result = await this.#sender.send(recipientOptions, notification);
64
+ options.onProgress();
65
+ return result;
66
+ }).withConcurrency(options.concurrency);
67
+ if (options.retries > 0) builder.withRetryPerTask(options.retries, { delay: backoff.exponentialWithJitter({
68
+ initial: 100,
69
+ max: 5e3
70
+ }) });
71
+ if (options.timeout) builder.withTimeoutPerTask(options.timeout);
72
+ if (!options.continueOnError) return builder.execute();
73
+ return (await builder.settle()).map((result) => result.status === "fulfilled" ? result.value : {
74
+ success: 0,
75
+ failed: 1,
76
+ results: []
77
+ });
78
+ }
79
+ /**
80
+ * Combines multiple send results into a single aggregated result
81
+ */
82
+ #aggregateResults(results) {
83
+ return {
84
+ success: results.reduce((sum, r) => sum + r.success, 0),
85
+ failed: results.reduce((sum, r) => sum + r.failed, 0),
86
+ results: results.flatMap((r) => r.results)
87
+ };
88
+ }
89
+ };
90
+
91
+ //#endregion
92
+ export { OrchestrationSender };
@@ -1,9 +1,9 @@
1
- import { DatabaseAdapter } from "./database/types.js";
2
- import { Channel } from "./types/channel.js";
3
- import { Emitter } from "./types/events.js";
4
- import { QueueAdapter } from "./types/queue.js";
5
- import { ResolvedDefaultPreferences } from "./types/preferences.js";
6
- import { FacteurConfiguration, NotificationResolver } from "./types/options.js";
1
+ import { Channel } from "./types/channel.mjs";
2
+ import { QueueAdapter } from "./types/queue.mjs";
3
+ import { ResolvedDefaultPreferences } from "./types/preferences.mjs";
4
+ import { Emitter } from "./types/events.mjs";
5
+ import { FacteurConfiguration, NotificationResolver, RetryConfig } from "./types/options.mjs";
6
+ import { DatabaseAdapter } from "./database/types.mjs";
7
7
  import { Logger } from "@julr/utils/logger";
8
8
 
9
9
  //#region src/options.d.ts
@@ -15,6 +15,7 @@ declare class FacteurOptions<KnownChannels extends Record<string, Channel>, DBAd
15
15
  queueAdapter: QueueAdapter;
16
16
  databaseAdapter: DBAdapter | null;
17
17
  notificationResolver: NotificationResolver;
18
+ retry: RetryConfig<KnownChannels>;
18
19
  readonly defaultPreferences: ResolvedDefaultPreferences<KnownChannels>;
19
20
  constructor(config: FacteurConfiguration<KnownChannels, DBAdapter>);
20
21
  }
@@ -1,4 +1,4 @@
1
- import { errors } from "./errors/index.js";
1
+ import { errors } from "./errors/index.mjs";
2
2
  import EventEmitter from "node:events";
3
3
  import { noopLogger } from "@julr/utils/logger";
4
4
  import { invoke } from "@julr/utils/functions";
@@ -11,6 +11,7 @@ var FacteurOptions = class {
11
11
  queueAdapter;
12
12
  databaseAdapter = null;
13
13
  notificationResolver;
14
+ retry = {};
14
15
  defaultPreferences;
15
16
  #resolveDefaultPreferences(preferences) {
16
17
  preferences = preferences || {};
@@ -23,11 +24,10 @@ var FacteurOptions = class {
23
24
  * Build categories preferences
24
25
  */
25
26
  const categories = Object.entries(preferences.categories || {}).map(([category, config]) => {
26
- const value = invoke(() => {
27
+ return [category, { channels: invoke(() => {
27
28
  if (typeof config === "boolean") return Object.fromEntries(allChannels.map((channel) => [channel, config]));
28
29
  return Object.assign({}, globalChannelPreferences, config.channels || {});
29
- });
30
- return [category, { channels: value }];
30
+ }) }];
31
31
  });
32
32
  return {
33
33
  enabled: preferences.enabled ?? true,
@@ -42,6 +42,7 @@ var FacteurOptions = class {
42
42
  this.databaseAdapter = config.databaseAdapter ?? null;
43
43
  this.defaultPreferences = this.#resolveDefaultPreferences(config.preferences);
44
44
  this.notificationResolver = config.notificationResolver || ((notification, ctx) => new notification(ctx));
45
+ this.retry = config.retry || {};
45
46
  const throwIfQueueNotSet = () => {
46
47
  throw new errors.E_QUEUE_NOT_SET();
47
48
  };
@@ -0,0 +1,122 @@
1
+ import { ChannelName } from "./extend.mjs";
2
+ import { BulkSendOptions, ChannelSpecificConfig, ExtractNotifiable, ExtractParams, RetryOptions } from "./options.mjs";
3
+ import { NotificationClass, NotificationSendResult } from "./notifications.mjs";
4
+ import { Identifier } from "../database/types.mjs";
5
+ import { Duration } from "@julr/tenace/types";
6
+
7
+ //#region src/types/builder.d.ts
8
+
9
+ /**
10
+ * Helper type to check if params are required for a notification
11
+ */
12
+ type HasRequiredParams<T extends NotificationClass<any, any>> = ExtractParams<T> extends Record<string, never> ? false : unknown extends ExtractParams<T> ? false : true;
13
+ /**
14
+ * Helper type to check if a notification is anonymous (no notifiable)
15
+ */
16
+ type IsAnonymous<T extends NotificationClass<any, any>> = ExtractNotifiable<T> extends undefined ? true : false;
17
+ /**
18
+ * State tracking for the builder
19
+ */
20
+ interface BuilderState {
21
+ hasParams: boolean;
22
+ hasTo: boolean;
23
+ hasVia: boolean;
24
+ }
25
+ /**
26
+ * Internal options accumulated by the builder. Extends BulkSendOptions for bulk operations.
27
+ */
28
+ interface BuilderOptions<TNotification extends NotificationClass<any, any>> extends BulkSendOptions {
29
+ notification: TNotification;
30
+ params?: ExtractParams<TNotification>;
31
+ to?: ExtractNotifiable<TNotification> | ExtractNotifiable<TNotification>[] | unknown;
32
+ via?: ChannelSpecificConfig<any>;
33
+ tenantId?: Identifier;
34
+ throwOnError?: boolean;
35
+ }
36
+ /**
37
+ * Minimal options needed by NotificationSender
38
+ */
39
+ interface InternalSendOptions extends RetryOptions {
40
+ notification: NotificationClass<any, any>;
41
+ params?: Record<string, any>;
42
+ to?: unknown;
43
+ via?: ChannelSpecificConfig<any>;
44
+ tenantId?: Identifier;
45
+ throwOnError?: boolean;
46
+ disableDriverBatch?: boolean;
47
+ }
48
+ /**
49
+ * Merges builder state updates. Used to track which methods have been called.
50
+ */
51
+ type MergeState<TState extends BuilderState, TUpdate extends Partial<BuilderState>> = {
52
+ hasParams: TUpdate extends {
53
+ hasParams: infer P;
54
+ } ? P : TState['hasParams'];
55
+ hasTo: TUpdate extends {
56
+ hasTo: infer T;
57
+ } ? T : TState['hasTo'];
58
+ hasVia: TUpdate extends {
59
+ hasVia: infer V;
60
+ } ? V : TState['hasVia'];
61
+ };
62
+ /**
63
+ * Methods available on all builders: bulk options, retry, and tenant selection.
64
+ */
65
+ interface CommonBuilderMethods<TNotification extends NotificationClass<any, any>, TState extends BuilderState> {
66
+ tenant(id: Identifier): NotificationBuilder<TNotification, TState>;
67
+ chunkSize(size: number): NotificationBuilder<TNotification, TState>;
68
+ concurrency(limit: number): NotificationBuilder<TNotification, TState>;
69
+ continueOnError(value?: boolean): NotificationBuilder<TNotification, TState>;
70
+ retries(count: number): NotificationBuilder<TNotification, TState>;
71
+ timeout(duration: Duration): NotificationBuilder<TNotification, TState>;
72
+ throwOnError(value?: boolean): NotificationBuilder<TNotification, TState>;
73
+ useDriverBatching(value?: boolean): NotificationBuilder<TNotification, TState>;
74
+ disableDriverBatch(value?: boolean): NotificationBuilder<TNotification, TState>;
75
+ onProgress(callback: (completed: number, total: number) => void): NotificationBuilder<TNotification, TState>;
76
+ }
77
+ /**
78
+ * Override channel targets directly (e.g., send to a specific email address).
79
+ */
80
+ interface ViaMethod<TNotification extends NotificationClass<any, any>, TState extends BuilderState> {
81
+ via(config: { [K in ChannelName]?: boolean | any }): NotificationBuilder<TNotification, MergeState<TState, {
82
+ hasVia: true;
83
+ }>>;
84
+ }
85
+ /**
86
+ * params() method type - always available but changes state
87
+ */
88
+ interface ParamsMethod<TNotification extends NotificationClass<any, any>, TState extends BuilderState> {
89
+ params(params: ExtractParams<TNotification>): NotificationBuilder<TNotification, MergeState<TState, {
90
+ hasParams: true;
91
+ }>>;
92
+ }
93
+ /**
94
+ * Set recipients. Accepts single, array, or async iterable. Not available for anonymous notifications.
95
+ */
96
+ interface ToMethod<TNotification extends NotificationClass<any, any>, TState extends BuilderState> {
97
+ to(recipients: NonNullable<ExtractNotifiable<TNotification>> | NonNullable<ExtractNotifiable<TNotification>>[] | AsyncIterable<NonNullable<ExtractNotifiable<TNotification>>>): NotificationBuilder<TNotification, MergeState<TState, {
98
+ hasTo: true;
99
+ }>>;
100
+ }
101
+ /**
102
+ * send() method type
103
+ */
104
+ interface SendMethod {
105
+ send(): Promise<NotificationSendResult>;
106
+ }
107
+ /**
108
+ * Returns true if send() should be available. Requires:
109
+ * - params() called if notification has required params
110
+ * - to() called for non-anonymous, or via() called for anonymous
111
+ */
112
+ type CanSend<TNotification extends NotificationClass<any, any>, TState extends BuilderState> = HasRequiredParams<TNotification> extends true ? TState['hasParams'] extends true ? CheckToOrVia<TNotification, TState> : false : CheckToOrVia<TNotification, TState>;
113
+ /**
114
+ * Anonymous notifications require via(), non-anonymous require to().
115
+ */
116
+ type CheckToOrVia<TNotification extends NotificationClass<any, any>, TState extends BuilderState> = IsAnonymous<TNotification> extends true ? TState['hasVia'] extends true ? true : false : TState['hasTo'] extends true ? true : false;
117
+ /**
118
+ * Fluent builder for sending notifications. Methods appear/disappear based on state.
119
+ */
120
+ type NotificationBuilder<TNotification extends NotificationClass<any, any>, TState extends BuilderState> = CommonBuilderMethods<TNotification, TState> & ViaMethod<TNotification, TState> & (TState['hasParams'] extends true ? {} : ParamsMethod<TNotification, TState>) & (IsAnonymous<TNotification> extends true ? {} : TState['hasTo'] extends true ? {} : ToMethod<TNotification, TState>) & (CanSend<TNotification, TState> extends true ? SendMethod : {});
121
+ //#endregion
122
+ export { BuilderOptions, BuilderState, HasRequiredParams, InternalSendOptions, IsAnonymous, NotificationBuilder };
@@ -0,0 +1,56 @@
1
+ import { Identifier } from "../database/types.mjs";
2
+ import { Awaitable } from "@julr/utils/types";
3
+
4
+ //#region src/types/channel.d.ts
5
+ type ChannelSendParams<Message, Targets> = {
6
+ to?: any;
7
+ message: Message;
8
+ targets?: Targets;
9
+ tenantId?: Identifier | undefined;
10
+ };
11
+ /**
12
+ * Result of a batch send operation
13
+ */
14
+ interface BatchSendResult {
15
+ success: number;
16
+ failed: number;
17
+ results: Array<{
18
+ index: number;
19
+ status: 'success' | 'failed';
20
+ error?: Error;
21
+ response?: any;
22
+ }>;
23
+ }
24
+ /**
25
+ * Configuration for batch sending capabilities
26
+ */
27
+ interface BatchConfig {
28
+ /**
29
+ * Maximum number of messages per batch API call.
30
+ * E.g. 500 for FCM, 100 for Expo
31
+ */
32
+ maxSize: number;
33
+ /**
34
+ * Whether batching is enabled for this channel.
35
+ * @default true
36
+ */
37
+ enabled?: boolean;
38
+ }
39
+ declare const kTargetSymbol: unique symbol;
40
+ interface Channel<_Options = any, Message = any, Response = any, Targets = any> {
41
+ [kTargetSymbol]: Targets;
42
+ name: string;
43
+ send: (options: ChannelSendParams<Message, Targets>) => Awaitable<Response>;
44
+ /**
45
+ * Optional batch send method for providers that support sending multiple messages in one API call.
46
+ * When implemented, the orchestration layer will group messages and use this method.
47
+ */
48
+ sendBatch?: (messages: ChannelSendParams<Message, Targets>[]) => Awaitable<BatchSendResult>;
49
+ /**
50
+ * Configuration for batch sending.
51
+ * Required when sendBatch is implemented.
52
+ */
53
+ batchConfig?: BatchConfig;
54
+ }
55
+ //#endregion
56
+ export { BatchConfig, BatchSendResult, Channel, ChannelSendParams, kTargetSymbol };
@@ -1,4 +1,4 @@
1
- import { facteurEvents } from "../events/events.js";
1
+ import { facteurEvents } from "../events/events.mjs";
2
2
 
3
3
  //#region src/types/events.d.ts
4
4