vintasend 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -106,6 +106,59 @@ export function sendWelcomeEmail(userId: number) {
106
106
  }
107
107
  ```
108
108
 
109
+ ## Multi-Backend Configuration
110
+
111
+ VintaSend supports configuring multiple backends for redundancy, data distribution, and migration use cases.
112
+
113
+ ### Basic Setup
114
+
115
+ ```typescript
116
+ import { VintaSendFactory } from 'vintasend';
117
+
118
+ const vintasend = new VintaSendFactory<NotificationTypeConfig>().create({
119
+ adapters,
120
+ backend: primaryBackend,
121
+ additionalBackends: [replicaBackend],
122
+ logger,
123
+ contextGeneratorsMap,
124
+ });
125
+ ```
126
+
127
+ ### How It Works
128
+
129
+ - **Writes**: VintaSend writes to the primary backend first, then replicates to additional backends on a best-effort basis.
130
+ - **Reads**: Read methods use the primary backend by default, but support optional backend targeting by identifier.
131
+
132
+ ```typescript
133
+ // Read from primary backend (default)
134
+ const notification = await vintasend.getNotification(notificationId);
135
+
136
+ // Read from a specific backend
137
+ const notificationFromReplica = await vintasend.getNotification(
138
+ notificationId,
139
+ false,
140
+ 'replica-backend',
141
+ );
142
+ ```
143
+
144
+ ### Backend Management Operations
145
+
146
+ ```typescript
147
+ const report = await vintasend.verifyNotificationSync(notificationId);
148
+
149
+ if (!report.synced) {
150
+ await vintasend.replicateNotification(notificationId);
151
+ }
152
+
153
+ const backendStats = await vintasend.getBackendSyncStats();
154
+ ```
155
+
156
+ ### Failure Handling
157
+
158
+ - Primary backend failures fail the operation.
159
+ - Additional backend replication failures are logged and do not fail the primary operation.
160
+ - This keeps primary workflows available while still enabling redundancy.
161
+
109
162
  ## Attachment Support
110
163
 
111
164
  VintaSend supports file attachments for notifications with an extensible architecture that allows you to choose your preferred storage backend.
@@ -1,8 +1,9 @@
1
1
  import type { StoredAttachment } from '../../types/attachment';
2
- import type { JsonValue } from '../../types/json-values';
2
+ import type { JsonObject, JsonValue } from '../../types/json-values';
3
3
  import type { AnyDatabaseNotification, DatabaseOneOffNotification } from '../../types/notification';
4
4
  import type { NotificationType } from '../../types/notification-type';
5
5
  import type { BaseNotificationTypeConfig } from '../../types/notification-type-config';
6
+ import type { EmailTemplate, EmailTemplateContent } from '../notification-template-renderers/base-email-template-renderer';
6
7
  import type { BaseLogger } from '../loggers/base-logger';
7
8
  import type { BaseNotificationBackend } from '../notification-backends/base-notification-backend';
8
9
  import type { BaseNotificationTemplateRenderer } from '../notification-template-renderers/base-notification-template-renderer';
@@ -45,4 +46,5 @@ export declare abstract class BaseNotificationAdapter<TemplateRenderer extends B
45
46
  };
46
47
  injectBackend(backend: BaseNotificationBackend<Config>): void;
47
48
  injectLogger(logger: BaseLogger): void;
49
+ renderFromTemplateContent(notification: AnyDatabaseNotification<Config>, templateContent: EmailTemplateContent, context: JsonObject): Promise<EmailTemplate>;
48
50
  }
@@ -90,5 +90,12 @@ class BaseNotificationAdapter {
90
90
  injectLogger(logger) {
91
91
  this.logger = logger;
92
92
  }
93
+ async renderFromTemplateContent(notification, templateContent, context) {
94
+ if (typeof this.templateRenderer.renderFromTemplateContent !== 'function') {
95
+ throw new Error('Template renderer does not support renderFromTemplateContent.');
96
+ }
97
+ const rendered = await this.templateRenderer.renderFromTemplateContent(notification, templateContent, context);
98
+ return rendered;
99
+ }
93
100
  }
94
101
  exports.BaseNotificationAdapter = BaseNotificationAdapter;
@@ -87,13 +87,22 @@ export declare const DEFAULT_BACKEND_FILTER_CAPABILITIES: {
87
87
  'stringLookups.caseInsensitive': boolean;
88
88
  };
89
89
  export interface BaseNotificationBackend<Config extends BaseNotificationTypeConfig> {
90
+ /**
91
+ * Get a unique identifier for this backend instance.
92
+ *
93
+ * Used to distinguish between multiple backend instances in a multi-backend setup.
94
+ * When not implemented, callers should use a fallback identifier strategy.
95
+ */
96
+ getBackendIdentifier?(): string;
90
97
  getAllPendingNotifications(): Promise<AnyDatabaseNotification<Config>[]>;
91
98
  getPendingNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
92
99
  getAllFutureNotifications(): Promise<AnyDatabaseNotification<Config>[]>;
93
100
  getFutureNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
94
101
  getAllFutureNotificationsFromUser(userId: Config['UserIdType']): Promise<DatabaseNotification<Config>[]>;
95
102
  getFutureNotificationsFromUser(userId: Config['UserIdType'], page: number, pageSize: number): Promise<DatabaseNotification<Config>[]>;
96
- persistNotification(notification: Omit<Notification<Config>, 'id'>): Promise<DatabaseNotification<Config>>;
103
+ persistNotification(notification: Omit<Notification<Config>, 'id'> & {
104
+ id?: Config['NotificationIdType'];
105
+ }): Promise<DatabaseNotification<Config>>;
97
106
  getAllNotifications(): Promise<AnyDatabaseNotification<Config>[]>;
98
107
  getNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
99
108
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
@@ -107,7 +116,9 @@ export interface BaseNotificationBackend<Config extends BaseNotificationTypeConf
107
116
  filterInAppUnreadNotifications(userId: Config['UserIdType'], page: number, pageSize: number): Promise<DatabaseNotification<Config>[]>;
108
117
  getUserEmailFromNotification(notificationId: Config['NotificationIdType']): Promise<string | undefined>;
109
118
  storeAdapterAndContextUsed(notificationId: Config['NotificationIdType'], adapterKey: string, context: InputJsonValue): Promise<void>;
110
- persistOneOffNotification(notification: Omit<OneOffNotificationInput<Config>, 'id'>): Promise<DatabaseOneOffNotification<Config>>;
119
+ persistOneOffNotification(notification: Omit<OneOffNotificationInput<Config>, 'id'> & {
120
+ id?: Config['NotificationIdType'];
121
+ }): Promise<DatabaseOneOffNotification<Config>>;
111
122
  persistOneOffNotificationUpdate(notificationId: Config['NotificationIdType'], notification: Partial<Omit<OneOffNotificationInput<Config>, 'id'>>): Promise<DatabaseOneOffNotification<Config>>;
112
123
  getOneOffNotification(notificationId: Config['NotificationIdType'], forUpdate: boolean): Promise<DatabaseOneOffNotification<Config> | null>;
113
124
  getAllOneOffNotifications(): Promise<DatabaseOneOffNotification<Config>[]>;
@@ -8,13 +8,21 @@ import type { BaseLogger } from './loggers/base-logger';
8
8
  import { type BaseNotificationAdapter } from './notification-adapters/base-notification-adapter';
9
9
  import { type BaseNotificationBackend, type NotificationFilterFields } from './notification-backends/base-notification-backend';
10
10
  import type { BaseNotificationQueueService } from './notification-queue-service/base-notification-queue-service';
11
+ import type { EmailTemplate, EmailTemplateContent } from './notification-template-renderers/base-email-template-renderer';
11
12
  import type { BaseNotificationTemplateRenderer } from './notification-template-renderers/base-notification-template-renderer';
12
13
  type VintaSendOptions = {
13
14
  raiseErrorOnFailedSend: boolean;
14
15
  };
16
+ type RenderEmailTemplateContextInput<Config extends BaseNotificationTypeConfig> = {
17
+ context: JsonObject;
18
+ } | {
19
+ contextName: string & keyof Config['ContextMap'];
20
+ contextParameters: JsonObject;
21
+ };
15
22
  type VintaSendFactoryCreateParams<Config extends BaseNotificationTypeConfig, AdaptersList extends BaseNotificationAdapter<BaseNotificationTemplateRenderer<Config>, Config>[], Backend extends BaseNotificationBackend<Config>, Logger extends BaseLogger, QueueService extends BaseNotificationQueueService<Config>, AttachmentMgr extends BaseAttachmentManager> = {
16
23
  adapters: AdaptersList;
17
24
  backend: Backend;
25
+ additionalBackends?: Backend[];
18
26
  logger: Logger;
19
27
  contextGeneratorsMap: BaseNotificationTypeConfig['ContextMap'];
20
28
  queueService?: QueueService;
@@ -53,7 +61,7 @@ export declare class VintaSendFactory<Config extends BaseNotificationTypeConfig>
53
61
  /**
54
62
  * @deprecated Use the object parameter overload instead.
55
63
  */
56
- create<AdaptersList extends BaseNotificationAdapter<BaseNotificationTemplateRenderer<Config>, Config>[], Backend extends BaseNotificationBackend<Config>, Logger extends BaseLogger, QueueService extends BaseNotificationQueueService<Config>, AttachmentMgr extends BaseAttachmentManager>(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: BaseNotificationTypeConfig['ContextMap'], queueService?: QueueService, attachmentManager?: AttachmentMgr, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider): VintaSend<Config, AdaptersList, Backend, Logger, QueueService, AttachmentMgr>;
64
+ create<AdaptersList extends BaseNotificationAdapter<BaseNotificationTemplateRenderer<Config>, Config>[], Backend extends BaseNotificationBackend<Config>, Logger extends BaseLogger, QueueService extends BaseNotificationQueueService<Config>, AttachmentMgr extends BaseAttachmentManager>(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: BaseNotificationTypeConfig['ContextMap'], queueService?: QueueService, attachmentManager?: AttachmentMgr, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider, additionalBackends?: Backend[]): VintaSend<Config, AdaptersList, Backend, Logger, QueueService, AttachmentMgr>;
57
65
  }
58
66
  export declare class VintaSend<Config extends BaseNotificationTypeConfig, AdaptersList extends BaseNotificationAdapter<BaseNotificationTemplateRenderer<Config>, Config>[], Backend extends BaseNotificationBackend<Config>, Logger extends BaseLogger, QueueService extends BaseNotificationQueueService<Config>, AttachmentMgr extends BaseAttachmentManager> {
59
67
  private adapters;
@@ -64,7 +72,25 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
64
72
  private options;
65
73
  private gitCommitShaProvider?;
66
74
  private contextGeneratorsMap;
67
- constructor(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: Config['ContextMap'], queueService?: QueueService | undefined, attachmentManager?: AttachmentMgr | undefined, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider | undefined);
75
+ private backends;
76
+ private primaryBackendIdentifier;
77
+ /**
78
+ * Creates a VintaSend instance with one primary backend and optional additional backends.
79
+ *
80
+ * In multi-backend mode:
81
+ * - writes execute on the primary backend first
82
+ * - additional backends receive best-effort replication
83
+ * - reads default to primary unless a backend identifier is provided
84
+ */
85
+ constructor(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: Config['ContextMap'], queueService?: QueueService | undefined, attachmentManager?: AttachmentMgr | undefined, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider | undefined, additionalBackends?: Backend[]);
86
+ private getBackendIdentifier;
87
+ private getBackend;
88
+ private getAdditionalBackends;
89
+ getPrimaryBackendIdentifier(): string;
90
+ getAllBackendIdentifiers(): string[];
91
+ getAdditionalBackendIdentifiers(): string[];
92
+ hasBackend(identifier: string): boolean;
93
+ private executeMultiBackendWrite;
68
94
  registerQueueService(queueService: QueueService): void;
69
95
  private normalizeGitCommitSha;
70
96
  private resolveGitCommitShaForExecution;
@@ -96,18 +122,34 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
96
122
  * @throws Error if the format is invalid
97
123
  */
98
124
  private validateEmailOrPhone;
99
- getAllFutureNotifications(): Promise<AnyDatabaseNotification<Config>[]>;
100
- getAllFutureNotificationsFromUser(userId: Config['NotificationIdType']): Promise<DatabaseNotification<Config>[]>;
101
- getFutureNotificationsFromUser(userId: Config['NotificationIdType'], page: number, pageSize: number): Promise<DatabaseNotification<Config>[]>;
102
- getFutureNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
125
+ getAllFutureNotifications(backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
126
+ getAllFutureNotificationsFromUser(userId: Config['NotificationIdType'], backendIdentifier?: string): Promise<DatabaseNotification<Config>[]>;
127
+ getFutureNotificationsFromUser(userId: Config['NotificationIdType'], page: number, pageSize: number, backendIdentifier?: string): Promise<DatabaseNotification<Config>[]>;
128
+ getFutureNotifications(page: number, pageSize: number, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
103
129
  getNotificationContext<ContextName extends string & keyof Config['ContextMap']>(contextName: ContextName, parameters: Parameters<ReturnType<typeof this.contextGeneratorsMap.getContextGenerator<ContextName>>['generate']>[0]): Promise<JsonObject>;
130
+ renderEmailTemplateFromContent(notification: AnyDatabaseNotification<Config>, templateContent: EmailTemplateContent, contextInput: RenderEmailTemplateContextInput<Config>): Promise<EmailTemplate>;
104
131
  sendPendingNotifications(): Promise<void>;
105
- getPendingNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
106
- getNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
107
- getOneOffNotifications(page: number, pageSize: number): Promise<DatabaseOneOffNotification<Config>[]>;
108
- getNotification(notificationId: Config['NotificationIdType'], forUpdate?: boolean): Promise<AnyDatabaseNotification<Config> | null>;
109
- filterNotifications(filter: NotificationFilterFields<Config>, page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
110
- getBackendSupportedFilterCapabilities(): Promise<{
132
+ /**
133
+ * Gets pending notifications from the primary backend by default or from a specific backend.
134
+ */
135
+ getPendingNotifications(page: number, pageSize: number, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
136
+ /**
137
+ * Gets notifications from the primary backend by default or from a specific backend.
138
+ */
139
+ getNotifications(page: number, pageSize: number, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
140
+ getOneOffNotifications(page: number, pageSize: number, backendIdentifier?: string): Promise<DatabaseOneOffNotification<Config>[]>;
141
+ /**
142
+ * Gets a notification by ID from the primary backend by default or from a specific backend.
143
+ */
144
+ getNotification(notificationId: Config['NotificationIdType'], forUpdate?: boolean, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config> | null>;
145
+ /**
146
+ * Filters notifications in the primary backend by default or in a specific backend.
147
+ */
148
+ filterNotifications(filter: NotificationFilterFields<Config>, page: number, pageSize: number, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
149
+ /**
150
+ * Returns the effective filter capabilities for the primary backend by default or for a specific backend.
151
+ */
152
+ getBackendSupportedFilterCapabilities(backendIdentifier?: string): Promise<{
111
153
  'logical.and': boolean;
112
154
  'logical.or': boolean;
113
155
  'logical.not': boolean;
@@ -135,15 +177,65 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
135
177
  *
136
178
  * @param notificationId - The ID of the one-off notification to retrieve
137
179
  * @param forUpdate - Whether the notification is being retrieved for update (default: false)
180
+ * @param backendIdentifier - Optional backend identifier. When omitted, the primary backend is used.
138
181
  * @returns The one-off notification or null if not found
139
182
  */
140
- getOneOffNotification(notificationId: Config['NotificationIdType'], forUpdate?: boolean): Promise<DatabaseOneOffNotification<Config> | null>;
183
+ getOneOffNotification(notificationId: Config['NotificationIdType'], forUpdate?: boolean, backendIdentifier?: string): Promise<DatabaseOneOffNotification<Config> | null>;
141
184
  markRead(notificationId: Config['NotificationIdType'], checkIsSent?: boolean): Promise<DatabaseNotification<Config>>;
142
- getInAppUnread(userId: Config['NotificationIdType']): Promise<DatabaseNotification<Config>[]>;
185
+ /**
186
+ * Gets unread in-app notifications from the primary backend by default or from a specific backend.
187
+ */
188
+ getInAppUnread(userId: Config['NotificationIdType'], backendIdentifier?: string): Promise<DatabaseNotification<Config>[]>;
143
189
  cancelNotification(notificationId: Config['NotificationIdType']): Promise<void>;
144
190
  resendNotification(notificationId: Config['NotificationIdType'], useStoredContextIfAvailable?: boolean): Promise<DatabaseNotification<Config> | undefined>;
145
191
  delayedSend(notificationId: Config['NotificationIdType']): Promise<void>;
146
192
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
147
- migrateToBackend<DestinationBackend extends BaseNotificationBackend<Config>>(destinationBackend: DestinationBackend, batchSize?: number): Promise<void>;
193
+ private normalizeValueForSyncComparison;
194
+ /**
195
+ * Verifies whether a notification is synchronized across all configured backends.
196
+ *
197
+ * The report includes backend-level existence/errors and field-level discrepancies
198
+ * when comparing additional backends against the primary backend.
199
+ */
200
+ verifyNotificationSync(notificationId: Config['NotificationIdType']): Promise<{
201
+ synced: boolean;
202
+ backends: Record<string, {
203
+ exists: boolean;
204
+ notification?: AnyDatabaseNotification<Config>;
205
+ error?: string;
206
+ }>;
207
+ discrepancies: string[];
208
+ }>;
209
+ /**
210
+ * Replicates one notification from the primary backend to all additional backends.
211
+ *
212
+ * If a notification already exists in an additional backend, it is updated.
213
+ * Otherwise, it is created.
214
+ */
215
+ replicateNotification(notificationId: Config['NotificationIdType']): Promise<{
216
+ successes: string[];
217
+ failures: {
218
+ backend: string;
219
+ error: string;
220
+ }[];
221
+ }>;
222
+ /**
223
+ * Returns a lightweight health snapshot for each configured backend.
224
+ */
225
+ getBackendSyncStats(): Promise<{
226
+ backends: Record<string, {
227
+ totalNotifications: number;
228
+ status: 'healthy' | 'error';
229
+ error?: string;
230
+ }>;
231
+ }>;
232
+ /**
233
+ * Migrates notifications from a source backend (primary by default) to a destination backend.
234
+ *
235
+ * @param destinationBackend - Backend receiving migrated records
236
+ * @param batchSize - Page size used while iterating source records
237
+ * @param sourceBackendIdentifier - Optional source backend identifier. Defaults to primary backend.
238
+ */
239
+ migrateToBackend<DestinationBackend extends BaseNotificationBackend<Config>>(destinationBackend: DestinationBackend, batchSize?: number, sourceBackendIdentifier?: string): Promise<void>;
148
240
  }
149
241
  export {};
@@ -7,14 +7,14 @@ const notification_context_generators_map_1 = require("./notification-context-ge
7
7
  class VintaSendFactory {
8
8
  create(adaptersOrParams, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options = {
9
9
  raiseErrorOnFailedSend: false,
10
- }, gitCommitShaProvider) {
10
+ }, gitCommitShaProvider, additionalBackends) {
11
11
  var _a;
12
12
  if (!Array.isArray(adaptersOrParams)) {
13
13
  return new VintaSend(adaptersOrParams.adapters, adaptersOrParams.backend, adaptersOrParams.logger, adaptersOrParams.contextGeneratorsMap, adaptersOrParams.queueService, adaptersOrParams.attachmentManager, (_a = adaptersOrParams.options) !== null && _a !== void 0 ? _a : {
14
14
  raiseErrorOnFailedSend: false,
15
- }, adaptersOrParams.gitCommitShaProvider);
15
+ }, adaptersOrParams.gitCommitShaProvider, adaptersOrParams.additionalBackends);
16
16
  }
17
- return new VintaSend(adaptersOrParams, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options, gitCommitShaProvider);
17
+ return new VintaSend(adaptersOrParams, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options, gitCommitShaProvider, additionalBackends);
18
18
  }
19
19
  }
20
20
  exports.VintaSendFactory = VintaSendFactory;
@@ -25,9 +25,17 @@ function hasAttachmentManagerInjection(backend) {
25
25
  typeof backend.injectAttachmentManager === 'function');
26
26
  }
27
27
  class VintaSend {
28
+ /**
29
+ * Creates a VintaSend instance with one primary backend and optional additional backends.
30
+ *
31
+ * In multi-backend mode:
32
+ * - writes execute on the primary backend first
33
+ * - additional backends receive best-effort replication
34
+ * - reads default to primary unless a backend identifier is provided
35
+ */
28
36
  constructor(adapters, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options = {
29
37
  raiseErrorOnFailedSend: false,
30
- }, gitCommitShaProvider) {
38
+ }, gitCommitShaProvider, additionalBackends = []) {
31
39
  this.adapters = adapters;
32
40
  this.backend = backend;
33
41
  this.logger = logger;
@@ -36,6 +44,25 @@ class VintaSend {
36
44
  this.options = options;
37
45
  this.gitCommitShaProvider = gitCommitShaProvider;
38
46
  this.contextGeneratorsMap = new notification_context_generators_map_1.NotificationContextGeneratorsMap(contextGeneratorsMap);
47
+ this.backends = new Map();
48
+ this.primaryBackendIdentifier = this.getBackendIdentifier(backend);
49
+ this.backends.set(this.primaryBackendIdentifier, backend);
50
+ for (const additionalBackend of additionalBackends) {
51
+ const additionalBackendIdentifier = this.getBackendIdentifier(additionalBackend);
52
+ if (this.backends.has(additionalBackendIdentifier)) {
53
+ throw new Error(`Duplicate backend identifier: ${additionalBackendIdentifier}`);
54
+ }
55
+ this.backends.set(additionalBackendIdentifier, additionalBackend);
56
+ if (typeof additionalBackend.injectLogger === 'function') {
57
+ additionalBackend.injectLogger(logger);
58
+ }
59
+ if (this.attachmentManager && hasAttachmentManagerInjection(additionalBackend)) {
60
+ additionalBackend.injectAttachmentManager(this.attachmentManager);
61
+ }
62
+ }
63
+ if (this.getAdditionalBackends().length !== additionalBackends.length) {
64
+ throw new Error('Invalid additional backends configuration');
65
+ }
39
66
  for (const adapter of adapters) {
40
67
  adapter.injectBackend(backend);
41
68
  adapter.injectLogger(logger);
@@ -55,6 +82,56 @@ class VintaSend {
55
82
  backend.injectAttachmentManager(this.attachmentManager);
56
83
  }
57
84
  }
85
+ getBackendIdentifier(backend) {
86
+ if (typeof backend.getBackendIdentifier === 'function') {
87
+ return backend.getBackendIdentifier();
88
+ }
89
+ return `backend-${this.backends.size}`;
90
+ }
91
+ getBackend(identifier) {
92
+ if (!identifier) {
93
+ return this.backend;
94
+ }
95
+ const backend = this.backends.get(identifier);
96
+ if (!backend) {
97
+ throw new Error(`Backend not found: ${identifier}`);
98
+ }
99
+ return backend;
100
+ }
101
+ getAdditionalBackends() {
102
+ return Array.from(this.backends.entries())
103
+ .filter(([identifier]) => identifier !== this.primaryBackendIdentifier)
104
+ .map(([, backend]) => backend);
105
+ }
106
+ getPrimaryBackendIdentifier() {
107
+ return this.primaryBackendIdentifier;
108
+ }
109
+ getAllBackendIdentifiers() {
110
+ return Array.from(this.backends.keys());
111
+ }
112
+ getAdditionalBackendIdentifiers() {
113
+ return this.getAllBackendIdentifiers().filter((identifier) => identifier !== this.primaryBackendIdentifier);
114
+ }
115
+ hasBackend(identifier) {
116
+ return this.backends.has(identifier);
117
+ }
118
+ async executeMultiBackendWrite(operation, primaryWrite, additionalWrite) {
119
+ const primaryResult = await primaryWrite(this.backend);
120
+ if (!additionalWrite) {
121
+ return primaryResult;
122
+ }
123
+ for (const additionalBackend of this.getAdditionalBackends()) {
124
+ const backendIdentifier = this.getBackendIdentifier(additionalBackend);
125
+ try {
126
+ await additionalWrite(additionalBackend, primaryResult);
127
+ this.logger.info(`${operation} replicated to backend ${backendIdentifier}`);
128
+ }
129
+ catch (replicationError) {
130
+ this.logger.error(`Failed to replicate ${operation} to backend ${backendIdentifier}: ${replicationError}`);
131
+ }
132
+ }
133
+ return primaryResult;
134
+ }
58
135
  registerQueueService(queueService) {
59
136
  this.queueService = queueService;
60
137
  }
@@ -85,19 +162,26 @@ class VintaSend {
85
162
  const oneOffNotificationUpdate = {
86
163
  gitCommitSha,
87
164
  };
88
- return this.backend.persistOneOffNotificationUpdate(notification.id, oneOffNotificationUpdate);
165
+ return this.executeMultiBackendWrite('persistOneOffNotificationGitCommitSha', async (backend) => {
166
+ return backend.persistOneOffNotificationUpdate(notification.id, oneOffNotificationUpdate);
167
+ }, async (backend) => {
168
+ await backend.persistOneOffNotificationUpdate(notification.id, oneOffNotificationUpdate);
169
+ });
89
170
  }
90
171
  const notificationUpdate = {
91
172
  gitCommitSha,
92
173
  };
93
- return this.backend.persistNotificationUpdate(notification.id, notificationUpdate);
174
+ return this.executeMultiBackendWrite('persistNotificationGitCommitSha', async (backend) => {
175
+ return backend.persistNotificationUpdate(notification.id, notificationUpdate);
176
+ }, async (backend) => {
177
+ await backend.persistNotificationUpdate(notification.id, notificationUpdate);
178
+ });
94
179
  }
95
180
  async resolveAndPersistGitCommitShaForExecution(notification) {
96
181
  const gitCommitSha = await this.resolveGitCommitShaForExecution();
97
182
  return this.persistGitCommitShaForExecution(notification, gitCommitSha);
98
183
  }
99
184
  async send(notification) {
100
- var _a;
101
185
  const notificationWithExecutionGitCommitSha = await this.resolveAndPersistGitCommitShaForExecution(notification);
102
186
  const adaptersOfType = this.adapters.filter((adapter) => adapter.notificationType === notificationWithExecutionGitCommitSha.notificationType);
103
187
  if (adaptersOfType.length === 0) {
@@ -152,7 +236,11 @@ class VintaSend {
152
236
  catch (sendError) {
153
237
  this.logger.error(`Error sending notification ${notificationWithExecutionGitCommitSha.id} with adapter ${adapter.key}: ${sendError}`);
154
238
  try {
155
- await this.backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
239
+ await this.executeMultiBackendWrite('markAsFailed', async (backend) => {
240
+ return backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
241
+ }, async (backend) => {
242
+ await backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
243
+ });
156
244
  }
157
245
  catch (markFailedError) {
158
246
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as failed: ${markFailedError}`);
@@ -160,13 +248,23 @@ class VintaSend {
160
248
  continue;
161
249
  }
162
250
  try {
163
- await this.backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
251
+ await this.executeMultiBackendWrite('markAsSent', async (backend) => {
252
+ return backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
253
+ }, async (backend) => {
254
+ await backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
255
+ });
164
256
  }
165
257
  catch (markSentError) {
166
258
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as sent: ${markSentError}`);
167
259
  }
168
260
  try {
169
- await this.backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, (_a = adapter.key) !== null && _a !== void 0 ? _a : 'unknown', context !== null && context !== void 0 ? context : {});
261
+ await this.executeMultiBackendWrite('storeAdapterAndContextUsed', async (backend) => {
262
+ var _a;
263
+ await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, (_a = adapter.key) !== null && _a !== void 0 ? _a : 'unknown', context !== null && context !== void 0 ? context : {});
264
+ }, async (backend) => {
265
+ var _a;
266
+ await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, (_a = adapter.key) !== null && _a !== void 0 ? _a : 'unknown', context !== null && context !== void 0 ? context : {});
267
+ });
170
268
  }
171
269
  catch (storeContextError) {
172
270
  this.logger.error(`Error storing adapter and context for notification ${notificationWithExecutionGitCommitSha.id}: ${storeContextError}`);
@@ -174,7 +272,14 @@ class VintaSend {
174
272
  }
175
273
  }
176
274
  async createNotification(notification) {
177
- const createdNotification = await this.backend.persistNotification(notification);
275
+ const createdNotification = await this.executeMultiBackendWrite('createNotification', async (backend) => {
276
+ return backend.persistNotification(notification);
277
+ }, async (backend, primaryResult) => {
278
+ await backend.persistNotification({
279
+ ...notification,
280
+ id: primaryResult.id,
281
+ });
282
+ });
178
283
  this.logger.info(`Notification ${createdNotification.id} created`);
179
284
  if (!notification.sendAfter || notification.sendAfter <= new Date()) {
180
285
  this.logger.info(`Notification ${createdNotification.id} sent immediately because sendAfter is null or in the past`);
@@ -186,7 +291,11 @@ class VintaSend {
186
291
  return createdNotification;
187
292
  }
188
293
  async updateNotification(notificationId, notification) {
189
- const updatedNotification = this.backend.persistNotificationUpdate(notificationId, notification);
294
+ const updatedNotification = this.executeMultiBackendWrite('updateNotification', async (backend) => {
295
+ return backend.persistNotificationUpdate(notificationId, notification);
296
+ }, async (backend) => {
297
+ await backend.persistNotificationUpdate(notificationId, notification);
298
+ });
190
299
  this.logger.info(`Notification ${notificationId} updated`);
191
300
  return updatedNotification;
192
301
  }
@@ -200,7 +309,14 @@ class VintaSend {
200
309
  async createOneOffNotification(notification) {
201
310
  // Validate email or phone format
202
311
  this.validateEmailOrPhone(notification.emailOrPhone);
203
- const createdNotification = await this.backend.persistOneOffNotification(notification);
312
+ const createdNotification = await this.executeMultiBackendWrite('createOneOffNotification', async (backend) => {
313
+ return backend.persistOneOffNotification(notification);
314
+ }, async (backend, primaryResult) => {
315
+ await backend.persistOneOffNotification({
316
+ ...notification,
317
+ id: primaryResult.id,
318
+ });
319
+ });
204
320
  this.logger.info(`One-off notification ${createdNotification.id} created`);
205
321
  if (!notification.sendAfter || notification.sendAfter <= new Date()) {
206
322
  this.logger.info(`One-off notification ${createdNotification.id} sent immediately`);
@@ -223,7 +339,11 @@ class VintaSend {
223
339
  if (notification.emailOrPhone !== undefined) {
224
340
  this.validateEmailOrPhone(notification.emailOrPhone);
225
341
  }
226
- const updatedNotification = await this.backend.persistOneOffNotificationUpdate(notificationId, notification);
342
+ const updatedNotification = await this.executeMultiBackendWrite('updateOneOffNotification', async (backend) => {
343
+ return backend.persistOneOffNotificationUpdate(notificationId, notification);
344
+ }, async (backend) => {
345
+ await backend.persistOneOffNotificationUpdate(notificationId, notification);
346
+ });
227
347
  this.logger.info(`One-off notification ${notificationId} updated`);
228
348
  if (!updatedNotification.sendAfter || updatedNotification.sendAfter <= new Date()) {
229
349
  this.logger.info(`One-off notification ${notificationId} sent after update`);
@@ -250,17 +370,17 @@ class VintaSend {
250
370
  throw new Error('Invalid email or phone format');
251
371
  }
252
372
  }
253
- async getAllFutureNotifications() {
254
- return this.backend.getAllFutureNotifications();
373
+ async getAllFutureNotifications(backendIdentifier) {
374
+ return this.getBackend(backendIdentifier).getAllFutureNotifications();
255
375
  }
256
- async getAllFutureNotificationsFromUser(userId) {
257
- return this.backend.getAllFutureNotificationsFromUser(userId);
376
+ async getAllFutureNotificationsFromUser(userId, backendIdentifier) {
377
+ return this.getBackend(backendIdentifier).getAllFutureNotificationsFromUser(userId);
258
378
  }
259
- async getFutureNotificationsFromUser(userId, page, pageSize) {
260
- return this.backend.getFutureNotificationsFromUser(userId, page, pageSize);
379
+ async getFutureNotificationsFromUser(userId, page, pageSize, backendIdentifier) {
380
+ return this.getBackend(backendIdentifier).getFutureNotificationsFromUser(userId, page, pageSize);
261
381
  }
262
- async getFutureNotifications(page, pageSize) {
263
- return this.backend.getFutureNotifications(page, pageSize);
382
+ async getFutureNotifications(page, pageSize, backendIdentifier) {
383
+ return this.getBackend(backendIdentifier).getFutureNotifications(page, pageSize);
264
384
  }
265
385
  async getNotificationContext(contextName, parameters) {
266
386
  const context = this.contextGeneratorsMap.getContextGenerator(contextName).generate(parameters);
@@ -269,49 +389,90 @@ class VintaSend {
269
389
  }
270
390
  return Promise.resolve(context);
271
391
  }
392
+ async renderEmailTemplateFromContent(notification, templateContent, contextInput) {
393
+ const adaptersOfType = this.adapters.filter((adapter) => adapter.notificationType === notification.notificationType);
394
+ if (adaptersOfType.length === 0) {
395
+ throw new Error(`No adapter found for notification type ${notification.notificationType}`);
396
+ }
397
+ const adapter = adaptersOfType[0];
398
+ const context = 'context' in contextInput
399
+ ? contextInput.context
400
+ : await this.getNotificationContext(contextInput.contextName, contextInput.contextParameters);
401
+ return adapter.renderFromTemplateContent(notification, templateContent, context);
402
+ }
272
403
  async sendPendingNotifications() {
273
- const pendingNotifications = await this.backend.getAllPendingNotifications();
404
+ const pendingNotifications = await this.getBackend().getAllPendingNotifications();
274
405
  await Promise.all(pendingNotifications.map((notification) => this.send(notification)));
275
406
  }
276
- async getPendingNotifications(page, pageSize) {
277
- return this.backend.getPendingNotifications(page, pageSize);
407
+ /**
408
+ * Gets pending notifications from the primary backend by default or from a specific backend.
409
+ */
410
+ async getPendingNotifications(page, pageSize, backendIdentifier) {
411
+ return this.getBackend(backendIdentifier).getPendingNotifications(page, pageSize);
278
412
  }
279
- async getNotifications(page, pageSize) {
280
- return this.backend.getNotifications(page, pageSize);
413
+ /**
414
+ * Gets notifications from the primary backend by default or from a specific backend.
415
+ */
416
+ async getNotifications(page, pageSize, backendIdentifier) {
417
+ return this.getBackend(backendIdentifier).getNotifications(page, pageSize);
281
418
  }
282
- async getOneOffNotifications(page, pageSize) {
283
- return this.backend.getOneOffNotifications(page, pageSize);
419
+ async getOneOffNotifications(page, pageSize, backendIdentifier) {
420
+ return this.getBackend(backendIdentifier).getOneOffNotifications(page, pageSize);
284
421
  }
285
- async getNotification(notificationId, forUpdate = false) {
286
- return this.backend.getNotification(notificationId, forUpdate);
422
+ /**
423
+ * Gets a notification by ID from the primary backend by default or from a specific backend.
424
+ */
425
+ async getNotification(notificationId, forUpdate = false, backendIdentifier) {
426
+ return this.getBackend(backendIdentifier).getNotification(notificationId, forUpdate);
287
427
  }
288
- async filterNotifications(filter, page, pageSize) {
289
- return this.backend.filterNotifications(filter, page, pageSize);
428
+ /**
429
+ * Filters notifications in the primary backend by default or in a specific backend.
430
+ */
431
+ async filterNotifications(filter, page, pageSize, backendIdentifier) {
432
+ return this.getBackend(backendIdentifier).filterNotifications(filter, page, pageSize);
290
433
  }
291
- async getBackendSupportedFilterCapabilities() {
434
+ /**
435
+ * Returns the effective filter capabilities for the primary backend by default or for a specific backend.
436
+ */
437
+ async getBackendSupportedFilterCapabilities(backendIdentifier) {
292
438
  var _a, _b, _c;
293
- return { ...base_notification_backend_1.DEFAULT_BACKEND_FILTER_CAPABILITIES, ...((_c = (_b = (_a = this.backend).getFilterCapabilities) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : {}) };
439
+ return {
440
+ ...base_notification_backend_1.DEFAULT_BACKEND_FILTER_CAPABILITIES,
441
+ ...((_c = (_b = (_a = this.getBackend(backendIdentifier)).getFilterCapabilities) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : {}),
442
+ };
294
443
  }
295
444
  /**
296
445
  * Gets a one-off notification by ID.
297
446
  *
298
447
  * @param notificationId - The ID of the one-off notification to retrieve
299
448
  * @param forUpdate - Whether the notification is being retrieved for update (default: false)
449
+ * @param backendIdentifier - Optional backend identifier. When omitted, the primary backend is used.
300
450
  * @returns The one-off notification or null if not found
301
451
  */
302
- async getOneOffNotification(notificationId, forUpdate = false) {
303
- return this.backend.getOneOffNotification(notificationId, forUpdate);
452
+ async getOneOffNotification(notificationId, forUpdate = false, backendIdentifier) {
453
+ return this.getBackend(backendIdentifier).getOneOffNotification(notificationId, forUpdate);
304
454
  }
305
455
  async markRead(notificationId, checkIsSent = true) {
306
- const notification = await this.backend.markAsRead(notificationId, checkIsSent);
456
+ const notification = await this.executeMultiBackendWrite('markRead', async (backend) => {
457
+ return backend.markAsRead(notificationId, checkIsSent);
458
+ }, async (backend) => {
459
+ await backend.markAsRead(notificationId, checkIsSent);
460
+ });
307
461
  this.logger.info(`Notification ${notificationId} marked as read`);
308
462
  return notification;
309
463
  }
310
- async getInAppUnread(userId) {
311
- return this.backend.filterAllInAppUnreadNotifications(userId);
464
+ /**
465
+ * Gets unread in-app notifications from the primary backend by default or from a specific backend.
466
+ */
467
+ async getInAppUnread(userId, backendIdentifier) {
468
+ return this.getBackend(backendIdentifier).filterAllInAppUnreadNotifications(userId);
312
469
  }
313
470
  async cancelNotification(notificationId) {
314
- await this.backend.cancelNotification(notificationId);
471
+ await this.executeMultiBackendWrite('cancelNotification', async (backend) => {
472
+ await backend.cancelNotification(notificationId);
473
+ }, async (backend) => {
474
+ await backend.cancelNotification(notificationId);
475
+ });
315
476
  this.logger.info(`Notification ${notificationId} cancelled`);
316
477
  }
317
478
  async resendNotification(notificationId, useStoredContextIfAvailable = false) {
@@ -404,32 +565,222 @@ class VintaSend {
404
565
  catch (sendError) {
405
566
  this.logger.error(`Error sending notification ${notificationWithExecutionGitCommitSha.id} with adapter ${adapter.key}: ${sendError}`);
406
567
  try {
407
- await this.backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
568
+ await this.executeMultiBackendWrite('markAsFailed', async (backend) => {
569
+ return backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
570
+ }, async (backend) => {
571
+ await backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
572
+ });
408
573
  }
409
574
  catch (markFailedError) {
410
575
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as failed: ${markFailedError}`);
411
576
  }
412
577
  }
413
578
  try {
414
- await this.backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
579
+ await this.executeMultiBackendWrite('markAsSent', async (backend) => {
580
+ return backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
581
+ }, async (backend) => {
582
+ await backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
583
+ });
415
584
  }
416
585
  catch (markSentError) {
417
586
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as sent: ${markSentError}`);
418
587
  }
419
588
  }
420
589
  try {
421
- await this.backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, lastAdapterKey, context);
590
+ await this.executeMultiBackendWrite('storeAdapterAndContextUsed', async (backend) => {
591
+ await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, lastAdapterKey, context);
592
+ }, async (backend) => {
593
+ await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, lastAdapterKey, context);
594
+ });
422
595
  }
423
596
  catch (storeContextError) {
424
597
  this.logger.error(`Error storing adapter and context for notification ${notificationWithExecutionGitCommitSha.id}: ${storeContextError}`);
425
598
  }
426
599
  }
427
600
  async bulkPersistNotifications(notifications) {
428
- return this.backend.bulkPersistNotifications(notifications);
601
+ return this.executeMultiBackendWrite('bulkPersistNotifications', async (backend) => {
602
+ return backend.bulkPersistNotifications(notifications);
603
+ }, async (backend, createdIds) => {
604
+ const notificationsWithIds = notifications.map((notification, index) => {
605
+ return {
606
+ ...notification,
607
+ id: createdIds[index],
608
+ };
609
+ });
610
+ await backend.bulkPersistNotifications(notificationsWithIds);
611
+ });
429
612
  }
430
- async migrateToBackend(destinationBackend, batchSize = 5000) {
613
+ normalizeValueForSyncComparison(value) {
614
+ if (value === null) {
615
+ return 'null';
616
+ }
617
+ if (value === undefined) {
618
+ return 'undefined';
619
+ }
620
+ if (value instanceof Date) {
621
+ return value.toISOString();
622
+ }
623
+ if (typeof value === 'object') {
624
+ try {
625
+ return JSON.stringify(value);
626
+ }
627
+ catch (_a) {
628
+ return '[unserializable-object]';
629
+ }
630
+ }
631
+ return String(value);
632
+ }
633
+ /**
634
+ * Verifies whether a notification is synchronized across all configured backends.
635
+ *
636
+ * The report includes backend-level existence/errors and field-level discrepancies
637
+ * when comparing additional backends against the primary backend.
638
+ */
639
+ async verifyNotificationSync(notificationId) {
640
+ var _a, _b, _c;
641
+ const report = {
642
+ synced: true,
643
+ backends: {},
644
+ discrepancies: [],
645
+ };
646
+ for (const [identifier, backend] of this.backends.entries()) {
647
+ try {
648
+ const notification = await backend.getNotification(notificationId, false);
649
+ report.backends[identifier] = {
650
+ exists: notification !== null,
651
+ notification: notification !== null && notification !== void 0 ? notification : undefined,
652
+ };
653
+ }
654
+ catch (error) {
655
+ report.backends[identifier] = {
656
+ exists: false,
657
+ error: String(error),
658
+ };
659
+ report.discrepancies.push(`Backend ${identifier}: ${String(error)}`);
660
+ report.synced = false;
661
+ }
662
+ }
663
+ const primaryNotification = (_a = report.backends[this.primaryBackendIdentifier]) === null || _a === void 0 ? void 0 : _a.notification;
664
+ if (!primaryNotification) {
665
+ report.synced = false;
666
+ report.discrepancies.push('Notification not found in primary backend');
667
+ return report;
668
+ }
669
+ for (const [identifier, backendReport] of Object.entries(report.backends)) {
670
+ if (identifier === this.primaryBackendIdentifier) {
671
+ continue;
672
+ }
673
+ if (!backendReport.exists) {
674
+ report.synced = false;
675
+ report.discrepancies.push(`Notification missing in backend: ${identifier}`);
676
+ continue;
677
+ }
678
+ if (((_b = backendReport.notification) === null || _b === void 0 ? void 0 : _b.status) !== primaryNotification.status) {
679
+ report.synced = false;
680
+ report.discrepancies.push(`Status mismatch in ${identifier}: ${String((_c = backendReport.notification) === null || _c === void 0 ? void 0 : _c.status)} vs ${String(primaryNotification.status)}`);
681
+ }
682
+ const primaryNotificationRecord = primaryNotification;
683
+ const backendNotificationRecord = backendReport.notification;
684
+ const fieldsToCompare = [
685
+ 'notificationType',
686
+ 'title',
687
+ 'bodyTemplate',
688
+ 'subjectTemplate',
689
+ 'contextName',
690
+ 'contextParameters',
691
+ 'contextUsed',
692
+ 'extraParams',
693
+ 'adapterUsed',
694
+ 'sendAfter',
695
+ 'sentAt',
696
+ 'readAt',
697
+ 'createdAt',
698
+ 'updatedAt',
699
+ 'gitCommitSha',
700
+ ];
701
+ for (const fieldName of fieldsToCompare) {
702
+ const primaryValue = this.normalizeValueForSyncComparison(primaryNotificationRecord[fieldName]);
703
+ const backendValue = this.normalizeValueForSyncComparison(backendNotificationRecord[fieldName]);
704
+ if (primaryValue !== backendValue) {
705
+ report.synced = false;
706
+ report.discrepancies.push(`Field mismatch in ${identifier} for ${fieldName}: ${backendValue} vs ${primaryValue}`);
707
+ }
708
+ }
709
+ }
710
+ return report;
711
+ }
712
+ /**
713
+ * Replicates one notification from the primary backend to all additional backends.
714
+ *
715
+ * If a notification already exists in an additional backend, it is updated.
716
+ * Otherwise, it is created.
717
+ */
718
+ async replicateNotification(notificationId) {
719
+ const primaryNotification = await this.backend.getNotification(notificationId, false);
720
+ if (!primaryNotification) {
721
+ throw new Error(`Notification ${String(notificationId)} not found in primary backend`);
722
+ }
723
+ const result = {
724
+ successes: [],
725
+ failures: [],
726
+ };
727
+ for (const backend of this.getAdditionalBackends()) {
728
+ const backendIdentifier = this.getBackendIdentifier(backend);
729
+ try {
730
+ const existingNotification = await backend.getNotification(notificationId, false);
731
+ if (existingNotification) {
732
+ await backend.persistNotificationUpdate(notificationId, primaryNotification);
733
+ }
734
+ else {
735
+ await backend.persistNotification(primaryNotification);
736
+ }
737
+ result.successes.push(backendIdentifier);
738
+ }
739
+ catch (error) {
740
+ result.failures.push({
741
+ backend: backendIdentifier,
742
+ error: String(error),
743
+ });
744
+ }
745
+ }
746
+ return result;
747
+ }
748
+ /**
749
+ * Returns a lightweight health snapshot for each configured backend.
750
+ */
751
+ async getBackendSyncStats() {
752
+ const stats = {
753
+ backends: {},
754
+ };
755
+ for (const [identifier, backend] of this.backends.entries()) {
756
+ try {
757
+ const notifications = await backend.getAllNotifications();
758
+ stats.backends[identifier] = {
759
+ totalNotifications: notifications.length,
760
+ status: 'healthy',
761
+ };
762
+ }
763
+ catch (error) {
764
+ stats.backends[identifier] = {
765
+ totalNotifications: 0,
766
+ status: 'error',
767
+ error: String(error),
768
+ };
769
+ }
770
+ }
771
+ return stats;
772
+ }
773
+ /**
774
+ * Migrates notifications from a source backend (primary by default) to a destination backend.
775
+ *
776
+ * @param destinationBackend - Backend receiving migrated records
777
+ * @param batchSize - Page size used while iterating source records
778
+ * @param sourceBackendIdentifier - Optional source backend identifier. Defaults to primary backend.
779
+ */
780
+ async migrateToBackend(destinationBackend, batchSize = 5000, sourceBackendIdentifier) {
781
+ const sourceBackend = this.getBackend(sourceBackendIdentifier);
431
782
  let pageNumber = 0;
432
- let allNotifications = await this.backend.getNotifications(pageNumber, batchSize);
783
+ let allNotifications = await sourceBackend.getNotifications(pageNumber, batchSize);
433
784
  while (allNotifications.length > 0) {
434
785
  pageNumber += 1;
435
786
  const notificationsWithoutId = allNotifications.map((notification) => {
@@ -437,7 +788,7 @@ class VintaSend {
437
788
  return notificationWithoutId;
438
789
  });
439
790
  await destinationBackend.bulkPersistNotifications(notificationsWithoutId);
440
- allNotifications = await this.backend.getNotifications(pageNumber, batchSize);
791
+ allNotifications = await sourceBackend.getNotifications(pageNumber, batchSize);
441
792
  }
442
793
  }
443
794
  }
@@ -9,8 +9,13 @@ export type EmailTemplate = {
9
9
  subject: string;
10
10
  body: string;
11
11
  };
12
+ export type EmailTemplateContent = {
13
+ subject: string | null;
14
+ body: string;
15
+ };
12
16
  export interface BaseEmailTemplateRenderer<Config extends BaseNotificationTypeConfig> extends BaseNotificationTemplateRenderer<Config, EmailTemplate> {
13
17
  render(notification: AnyNotification<Config>, context: JsonObject): Promise<EmailTemplate>;
18
+ renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: EmailTemplateContent, context: JsonObject): Promise<EmailTemplate>;
14
19
  /**
15
20
  * Inject logger into the template renderer (optional - called by VintaSend when logger exists)
16
21
  */
@@ -3,4 +3,5 @@ import type { AnyNotification } from '../../types/notification';
3
3
  import type { BaseNotificationTypeConfig } from '../../types/notification-type-config';
4
4
  export interface BaseNotificationTemplateRenderer<Config extends BaseNotificationTypeConfig, T = unknown> {
5
5
  render(notification: AnyNotification<Config>, context: JsonObject): Promise<T>;
6
+ renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: unknown, context: JsonObject): Promise<T>;
6
7
  }
@@ -6,6 +6,7 @@ import type { BaseNotificationTypeConfig } from './notification-type-config';
6
6
  import type { DatabaseOneOffNotification, OneOffNotification, OneOffNotificationInput } from './one-off-notification';
7
7
  export type { DatabaseOneOffNotification, OneOffNotification, OneOffNotificationInput, OneOffNotificationResendWithContextInput, } from './one-off-notification';
8
8
  export type NotificationInput<Config extends BaseNotificationTypeConfig> = {
9
+ id?: Config['NotificationIdType'];
9
10
  userId: Config['UserIdType'];
10
11
  notificationType: NotificationType;
11
12
  title: string | null;
@@ -19,6 +20,7 @@ export type NotificationInput<Config extends BaseNotificationTypeConfig> = {
19
20
  attachments?: NotificationAttachment[];
20
21
  };
21
22
  export type NotificationResendWithContextInput<Config extends BaseNotificationTypeConfig> = {
23
+ id?: Config['NotificationIdType'];
22
24
  userId: Config['UserIdType'];
23
25
  notificationType: NotificationType;
24
26
  title: string | null;
@@ -8,6 +8,7 @@ import type { BaseNotificationTypeConfig } from './notification-type-config';
8
8
  * One-off notifications are sent directly to an email/phone without requiring a user account.
9
9
  */
10
10
  export type OneOffNotificationInput<Config extends BaseNotificationTypeConfig> = {
11
+ id?: Config['NotificationIdType'];
11
12
  emailOrPhone: string;
12
13
  firstName: string;
13
14
  lastName: string;
@@ -27,6 +28,7 @@ export type OneOffNotificationInput<Config extends BaseNotificationTypeConfig> =
27
28
  * Similar to OneOffNotificationInput but includes the contextUsed field.
28
29
  */
29
30
  export type OneOffNotificationResendWithContextInput<Config extends BaseNotificationTypeConfig> = {
31
+ id?: Config['NotificationIdType'];
30
32
  emailOrPhone: string;
31
33
  firstName: string;
32
34
  lastName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vintasend",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "main": "dist/index.js",
5
5
  "files": [
6
6
  "dist"