vintasend 0.8.2 → 0.9.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
@@ -126,7 +126,7 @@ const vintasend = new VintaSendFactory<NotificationTypeConfig>().create({
126
126
 
127
127
  ### How It Works
128
128
 
129
- - **Writes**: VintaSend writes to the primary backend first, then replicates to additional backends on a best-effort basis.
129
+ - **Writes**: VintaSend writes to the primary backend first, then replicates to additional backends either inline (default) or through a per-backend replication queue.
130
130
  - **Reads**: Read methods use the primary backend by default, but support optional backend targeting by identifier.
131
131
 
132
132
  ```typescript
@@ -153,11 +153,71 @@ if (!report.synced) {
153
153
  const backendStats = await vintasend.getBackendSyncStats();
154
154
  ```
155
155
 
156
+ ### Asynchronous Replication Queue (Per Backend)
157
+
158
+ VintaSend supports asynchronous replication to additional backends with one queued task per destination backend.
159
+
160
+ #### Replication mode
161
+
162
+ Use `replicationMode: 'queued'` to enqueue replication instead of replicating inline in the request path.
163
+
164
+ ```typescript
165
+ const vintasend = new VintaSendFactory<NotificationTypeConfig>().create({
166
+ adapters,
167
+ backend: primaryBackend,
168
+ additionalBackends: [replicaA, replicaB],
169
+ logger,
170
+ contextGeneratorsMap,
171
+ replicationQueueService,
172
+ options: {
173
+ raiseErrorOnFailedSend: false,
174
+ replicationMode: 'queued',
175
+ },
176
+ });
177
+ ```
178
+
179
+ #### Replication queue contract
180
+
181
+ The replication queue service receives both the notification id and the target backend identifier:
182
+
183
+ ```typescript
184
+ export interface BaseNotificationReplicationQueueService<Config extends BaseNotificationTypeConfig> {
185
+ enqueueReplication(notificationId: Config['NotificationIdType'], backendIdentifier: string): Promise<void>;
186
+ }
187
+ ```
188
+
189
+ When queued replication is enabled, VintaSend enqueues one replication task for each additional backend.
190
+
191
+ #### Worker processing
192
+
193
+ Workers should process replication with a backend target to apply replication only for the queued destination:
194
+
195
+ ```typescript
196
+ await vintasend.processReplication(notificationId, backendIdentifier);
197
+ ```
198
+
199
+ You can still call `processReplication(notificationId)` without a target to process all additional backends.
200
+
201
+ #### Ordering and idempotency safety
202
+
203
+ To reduce out-of-order replication issues, backends can optionally implement conditional apply:
204
+
205
+ ```typescript
206
+ applyReplicationSnapshotIfNewer?(snapshot): Promise<{ applied: boolean }>;
207
+ ```
208
+
209
+ - If destination state is newer/equal, replication is skipped (`applied: false`).
210
+ - If destination is older, snapshot is applied.
211
+ - Duplicate-create race conditions are handled with create→update fallback in worker replication flow.
212
+
213
+ Official backends `vintasend-prisma` and `vintasend-medplum` implement this conditional apply behavior.
214
+
156
215
  ### Failure Handling
157
216
 
158
217
  - Primary backend failures fail the operation.
159
218
  - Additional backend replication failures are logged and do not fail the primary operation.
160
- - This keeps primary workflows available while still enabling redundancy.
219
+ - In queued mode, enqueue failures fall back to inline replication for affected backends.
220
+ - This keeps primary workflows available while still enabling redundancy and eventual consistency.
161
221
 
162
222
  ## Filtering and Ordering Notifications
163
223
 
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export type { BaseNotificationBackend } from './services/notification-backends/b
7
7
  export { supportsAttachments, isFieldFilter } from './services/notification-backends/base-notification-backend';
8
8
  export type { NotificationFilter, NotificationFilterFields, DateRange, NotificationFilterCapabilities, StringFilterLookup, StringFieldFilter, } from './services/notification-backends/base-notification-backend';
9
9
  export type { BaseNotificationQueueService } from './services/notification-queue-service/base-notification-queue-service';
10
+ export type { BaseNotificationReplicationQueueService } from './services/notification-queue-service/base-notification-replication-queue-service';
10
11
  export type { VintaSend } from './services/notification-service';
11
12
  export { VintaSendFactory } from './services/notification-service';
12
13
  export type { BaseEmailTemplateRenderer } from './services/notification-template-renderers/base-email-template-renderer';
@@ -118,6 +118,16 @@ export interface BaseNotificationBackend<Config extends BaseNotificationTypeConf
118
118
  getNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
119
119
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
120
120
  persistNotificationUpdate(notificationId: Config['NotificationIdType'], notification: Partial<Omit<Notification<Config>, 'id'>>): Promise<DatabaseNotification<Config>>;
121
+ /**
122
+ * Applies a replication snapshot only when the destination state is older.
123
+ *
124
+ * This is used to mitigate out-of-order async replication deliveries.
125
+ * Implementations should return `applied: false` when the destination notification
126
+ * is already newer or equal to the snapshot.
127
+ */
128
+ applyReplicationSnapshotIfNewer?(snapshot: AnyDatabaseNotification<Config>): Promise<{
129
+ applied: boolean;
130
+ }>;
121
131
  markAsSent(notificationId: Config['NotificationIdType'], checkIsPending: boolean): Promise<AnyDatabaseNotification<Config>>;
122
132
  markAsFailed(notificationId: Config['NotificationIdType'], checkIsPending: boolean): Promise<AnyDatabaseNotification<Config>>;
123
133
  markAsRead(notificationId: Config['NotificationIdType'], checkIsSent: boolean): Promise<DatabaseNotification<Config>>;
@@ -0,0 +1,4 @@
1
+ import type { BaseNotificationTypeConfig } from '../../types/notification-type-config';
2
+ export interface BaseNotificationReplicationQueueService<Config extends BaseNotificationTypeConfig> {
3
+ enqueueReplication(notificationId: Config['NotificationIdType'], backendIdentifier: string): Promise<void>;
4
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -8,10 +8,12 @@ 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, type NotificationOrderBy } from './notification-backends/base-notification-backend';
10
10
  import type { BaseNotificationQueueService } from './notification-queue-service/base-notification-queue-service';
11
+ import type { BaseNotificationReplicationQueueService } from './notification-queue-service/base-notification-replication-queue-service';
11
12
  import type { EmailTemplate, EmailTemplateContent } from './notification-template-renderers/base-email-template-renderer';
12
13
  import type { BaseNotificationTemplateRenderer } from './notification-template-renderers/base-notification-template-renderer';
13
14
  type VintaSendOptions = {
14
15
  raiseErrorOnFailedSend: boolean;
16
+ replicationMode?: 'inline' | 'queued';
15
17
  };
16
18
  type RenderEmailTemplateContextInput<Config extends BaseNotificationTypeConfig> = {
17
19
  context: JsonObject;
@@ -26,6 +28,7 @@ type VintaSendFactoryCreateParams<Config extends BaseNotificationTypeConfig, Ada
26
28
  logger: Logger;
27
29
  contextGeneratorsMap: BaseNotificationTypeConfig['ContextMap'];
28
30
  queueService?: QueueService;
31
+ replicationQueueService?: BaseNotificationReplicationQueueService<Config>;
29
32
  attachmentManager?: AttachmentMgr;
30
33
  options?: VintaSendOptions;
31
34
  gitCommitShaProvider?: BaseGitCommitShaProvider;
@@ -61,7 +64,7 @@ export declare class VintaSendFactory<Config extends BaseNotificationTypeConfig>
61
64
  /**
62
65
  * @deprecated Use the object parameter overload instead.
63
66
  */
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>;
67
+ 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[], replicationQueueService?: BaseNotificationReplicationQueueService<Config>): VintaSend<Config, AdaptersList, Backend, Logger, QueueService, AttachmentMgr>;
65
68
  }
66
69
  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> {
67
70
  private adapters;
@@ -71,6 +74,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
71
74
  private attachmentManager?;
72
75
  private options;
73
76
  private gitCommitShaProvider?;
77
+ private replicationQueueService?;
74
78
  private contextGeneratorsMap;
75
79
  private backends;
76
80
  private primaryBackendIdentifier;
@@ -82,7 +86,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
82
86
  * - additional backends receive best-effort replication
83
87
  * - reads default to primary unless a backend identifier is provided
84
88
  */
85
- constructor(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: Config['ContextMap'], queueService?: QueueService | undefined, attachmentManager?: AttachmentMgr | undefined, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider | undefined, additionalBackends?: Backend[]);
89
+ constructor(adapters: AdaptersList, backend: Backend, logger: Logger, contextGeneratorsMap: Config['ContextMap'], queueService?: QueueService | undefined, attachmentManager?: AttachmentMgr | undefined, options?: VintaSendOptions, gitCommitShaProvider?: BaseGitCommitShaProvider | undefined, additionalBackends?: Backend[], replicationQueueService?: BaseNotificationReplicationQueueService<Config> | undefined);
86
90
  private getBackendIdentifier;
87
91
  private getBackend;
88
92
  private getAdditionalBackends;
@@ -92,6 +96,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
92
96
  hasBackend(identifier: string): boolean;
93
97
  private executeMultiBackendWrite;
94
98
  registerQueueService(queueService: QueueService): void;
99
+ registerReplicationQueueService(replicationQueueService: BaseNotificationReplicationQueueService<Config>): void;
95
100
  private normalizeGitCommitSha;
96
101
  private resolveGitCommitShaForExecution;
97
102
  private persistGitCommitShaForExecution;
@@ -196,6 +201,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
196
201
  delayedSend(notificationId: Config['NotificationIdType']): Promise<void>;
197
202
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
198
203
  private normalizeValueForSyncComparison;
204
+ private isLikelyDuplicateReplicationConflict;
199
205
  /**
200
206
  * Verifies whether a notification is synchronized across all configured backends.
201
207
  *
@@ -211,6 +217,18 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
211
217
  }>;
212
218
  discrepancies: string[];
213
219
  }>;
220
+ /**
221
+ * Worker-facing replication entrypoint.
222
+ *
223
+ * Reads the notification from the primary backend and upserts into additional backends.
224
+ */
225
+ processReplication(notificationId: Config['NotificationIdType'], targetBackendIdentifier?: string): Promise<{
226
+ successes: string[];
227
+ failures: {
228
+ backend: string;
229
+ error: string;
230
+ }[];
231
+ }>;
214
232
  /**
215
233
  * Replicates one notification from the primary backend to all additional backends.
216
234
  *
@@ -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, additionalBackends) {
10
+ }, gitCommitShaProvider, additionalBackends, replicationQueueService) {
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, adaptersOrParams.additionalBackends);
15
+ }, adaptersOrParams.gitCommitShaProvider, adaptersOrParams.additionalBackends, adaptersOrParams.replicationQueueService);
16
16
  }
17
- return new VintaSend(adaptersOrParams, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options, gitCommitShaProvider, additionalBackends);
17
+ return new VintaSend(adaptersOrParams, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options, gitCommitShaProvider, additionalBackends, replicationQueueService);
18
18
  }
19
19
  }
20
20
  exports.VintaSendFactory = VintaSendFactory;
@@ -35,7 +35,7 @@ class VintaSend {
35
35
  */
36
36
  constructor(adapters, backend, logger, contextGeneratorsMap, queueService, attachmentManager, options = {
37
37
  raiseErrorOnFailedSend: false,
38
- }, gitCommitShaProvider, additionalBackends = []) {
38
+ }, gitCommitShaProvider, additionalBackends = [], replicationQueueService) {
39
39
  this.adapters = adapters;
40
40
  this.backend = backend;
41
41
  this.logger = logger;
@@ -43,6 +43,7 @@ class VintaSend {
43
43
  this.attachmentManager = attachmentManager;
44
44
  this.options = options;
45
45
  this.gitCommitShaProvider = gitCommitShaProvider;
46
+ this.replicationQueueService = replicationQueueService;
46
47
  this.contextGeneratorsMap = new notification_context_generators_map_1.NotificationContextGeneratorsMap(contextGeneratorsMap);
47
48
  this.backends = new Map();
48
49
  this.primaryBackendIdentifier = this.getBackendIdentifier(backend);
@@ -115,26 +116,97 @@ class VintaSend {
115
116
  hasBackend(identifier) {
116
117
  return this.backends.has(identifier);
117
118
  }
118
- async executeMultiBackendWrite(operation, primaryWrite, additionalWrite) {
119
+ async executeMultiBackendWrite(operation, primaryWrite, additionalWrite, replicationNotificationId) {
119
120
  const primaryResult = await primaryWrite(this.backend);
120
121
  if (!additionalWrite) {
121
122
  return primaryResult;
122
123
  }
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}`);
124
+ const additionalBackends = this.getAdditionalBackends();
125
+ if (additionalBackends.length === 0) {
126
+ return primaryResult;
127
+ }
128
+ const resolveReplicationNotificationId = () => {
129
+ if (typeof replicationNotificationId === 'function') {
130
+ return replicationNotificationId(primaryResult);
131
+ }
132
+ if (replicationNotificationId !== undefined) {
133
+ return replicationNotificationId;
134
+ }
135
+ const idFromPrimaryResult = primaryResult.id;
136
+ return idFromPrimaryResult;
137
+ };
138
+ const executeInlineReplication = async () => {
139
+ for (const additionalBackend of additionalBackends) {
140
+ const backendIdentifier = this.getBackendIdentifier(additionalBackend);
141
+ try {
142
+ await additionalWrite(additionalBackend, primaryResult);
143
+ this.logger.info(`${operation} replicated to backend ${backendIdentifier} in inline mode`);
144
+ }
145
+ catch (replicationError) {
146
+ this.logger.error(`Failed to replicate ${operation} to backend ${backendIdentifier}: ${replicationError}`);
147
+ }
148
+ }
149
+ };
150
+ if (this.options.replicationMode === 'queued') {
151
+ const notificationIdToReplicate = resolveReplicationNotificationId();
152
+ if (!notificationIdToReplicate) {
153
+ this.logger.warn(`Replication mode is queued, but no notification id was resolved for ${operation}. Falling back to inline replication.`);
154
+ await executeInlineReplication();
155
+ return primaryResult;
156
+ }
157
+ if (!this.replicationQueueService) {
158
+ this.logger.warn(`Replication mode is queued, but no replication queue service is registered for ${operation}. Falling back to inline replication.`);
159
+ await executeInlineReplication();
160
+ return primaryResult;
161
+ }
162
+ const enqueueResults = await Promise.all(additionalBackends.map(async (additionalBackend) => {
163
+ var _a;
164
+ const backendIdentifier = this.getBackendIdentifier(additionalBackend);
165
+ try {
166
+ await ((_a = this.replicationQueueService) === null || _a === void 0 ? void 0 : _a.enqueueReplication(notificationIdToReplicate, backendIdentifier));
167
+ return {
168
+ backendIdentifier,
169
+ backend: additionalBackend,
170
+ error: null,
171
+ };
172
+ }
173
+ catch (enqueueReplicationError) {
174
+ return {
175
+ backendIdentifier,
176
+ backend: additionalBackend,
177
+ error: String(enqueueReplicationError),
178
+ };
179
+ }
180
+ }));
181
+ const failedEnqueues = enqueueResults.filter((enqueueResult) => enqueueResult.error);
182
+ if (failedEnqueues.length === 0) {
183
+ this.logger.info(`${operation} replication enqueued for notification ${String(notificationIdToReplicate)} to ${additionalBackends.length} backend(s) in queued mode`);
184
+ return primaryResult;
185
+ }
186
+ for (const failedEnqueue of failedEnqueues) {
187
+ this.logger.error(`Failed to enqueue replication for ${operation}, notification ${String(notificationIdToReplicate)} and backend ${failedEnqueue.backendIdentifier}: ${failedEnqueue.error}`);
128
188
  }
129
- catch (replicationError) {
130
- this.logger.error(`Failed to replicate ${operation} to backend ${backendIdentifier}: ${replicationError}`);
189
+ this.logger.warn(`Falling back to inline replication for ${operation} in ${failedEnqueues.length} backend(s) after queue enqueue failure.`);
190
+ for (const failedEnqueue of failedEnqueues) {
191
+ try {
192
+ await additionalWrite(failedEnqueue.backend, primaryResult);
193
+ this.logger.info(`${operation} replicated to backend ${failedEnqueue.backendIdentifier} in inline fallback mode`);
194
+ }
195
+ catch (replicationError) {
196
+ this.logger.error(`Failed to replicate ${operation} to backend ${failedEnqueue.backendIdentifier} in inline fallback mode: ${replicationError}`);
197
+ }
131
198
  }
199
+ return primaryResult;
132
200
  }
201
+ await executeInlineReplication();
133
202
  return primaryResult;
134
203
  }
135
204
  registerQueueService(queueService) {
136
205
  this.queueService = queueService;
137
206
  }
207
+ registerReplicationQueueService(replicationQueueService) {
208
+ this.replicationQueueService = replicationQueueService;
209
+ }
138
210
  normalizeGitCommitSha(gitCommitSha) {
139
211
  const normalizedSha = gitCommitSha.trim().toLowerCase();
140
212
  if (!/^[a-f0-9]{40}$/.test(normalizedSha)) {
@@ -166,7 +238,7 @@ class VintaSend {
166
238
  return backend.persistOneOffNotificationUpdate(notification.id, oneOffNotificationUpdate);
167
239
  }, async (backend) => {
168
240
  await backend.persistOneOffNotificationUpdate(notification.id, oneOffNotificationUpdate);
169
- });
241
+ }, notification.id);
170
242
  }
171
243
  const notificationUpdate = {
172
244
  gitCommitSha,
@@ -175,7 +247,7 @@ class VintaSend {
175
247
  return backend.persistNotificationUpdate(notification.id, notificationUpdate);
176
248
  }, async (backend) => {
177
249
  await backend.persistNotificationUpdate(notification.id, notificationUpdate);
178
- });
250
+ }, notification.id);
179
251
  }
180
252
  async resolveAndPersistGitCommitShaForExecution(notification) {
181
253
  const gitCommitSha = await this.resolveGitCommitShaForExecution();
@@ -240,7 +312,7 @@ class VintaSend {
240
312
  return backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
241
313
  }, async (backend) => {
242
314
  await backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
243
- });
315
+ }, notificationWithExecutionGitCommitSha.id);
244
316
  }
245
317
  catch (markFailedError) {
246
318
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as failed: ${markFailedError}`);
@@ -252,7 +324,7 @@ class VintaSend {
252
324
  return backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
253
325
  }, async (backend) => {
254
326
  await backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
255
- });
327
+ }, notificationWithExecutionGitCommitSha.id);
256
328
  }
257
329
  catch (markSentError) {
258
330
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as sent: ${markSentError}`);
@@ -264,7 +336,7 @@ class VintaSend {
264
336
  }, async (backend) => {
265
337
  var _a;
266
338
  await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, (_a = adapter.key) !== null && _a !== void 0 ? _a : 'unknown', context !== null && context !== void 0 ? context : {});
267
- });
339
+ }, notificationWithExecutionGitCommitSha.id);
268
340
  }
269
341
  catch (storeContextError) {
270
342
  this.logger.error(`Error storing adapter and context for notification ${notificationWithExecutionGitCommitSha.id}: ${storeContextError}`);
@@ -295,7 +367,7 @@ class VintaSend {
295
367
  return backend.persistNotificationUpdate(notificationId, notification);
296
368
  }, async (backend) => {
297
369
  await backend.persistNotificationUpdate(notificationId, notification);
298
- });
370
+ }, notificationId);
299
371
  this.logger.info(`Notification ${notificationId} updated`);
300
372
  return updatedNotification;
301
373
  }
@@ -343,7 +415,7 @@ class VintaSend {
343
415
  return backend.persistOneOffNotificationUpdate(notificationId, notification);
344
416
  }, async (backend) => {
345
417
  await backend.persistOneOffNotificationUpdate(notificationId, notification);
346
- });
418
+ }, notificationId);
347
419
  this.logger.info(`One-off notification ${notificationId} updated`);
348
420
  if (!updatedNotification.sendAfter || updatedNotification.sendAfter <= new Date()) {
349
421
  this.logger.info(`One-off notification ${notificationId} sent after update`);
@@ -457,7 +529,7 @@ class VintaSend {
457
529
  return backend.markAsRead(notificationId, checkIsSent);
458
530
  }, async (backend) => {
459
531
  await backend.markAsRead(notificationId, checkIsSent);
460
- });
532
+ }, notificationId);
461
533
  this.logger.info(`Notification ${notificationId} marked as read`);
462
534
  return notification;
463
535
  }
@@ -472,7 +544,7 @@ class VintaSend {
472
544
  await backend.cancelNotification(notificationId);
473
545
  }, async (backend) => {
474
546
  await backend.cancelNotification(notificationId);
475
- });
547
+ }, notificationId);
476
548
  this.logger.info(`Notification ${notificationId} cancelled`);
477
549
  }
478
550
  async resendNotification(notificationId, useStoredContextIfAvailable = false) {
@@ -569,7 +641,7 @@ class VintaSend {
569
641
  return backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
570
642
  }, async (backend) => {
571
643
  await backend.markAsFailed(notificationWithExecutionGitCommitSha.id, true);
572
- });
644
+ }, notificationWithExecutionGitCommitSha.id);
573
645
  }
574
646
  catch (markFailedError) {
575
647
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as failed: ${markFailedError}`);
@@ -580,7 +652,7 @@ class VintaSend {
580
652
  return backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
581
653
  }, async (backend) => {
582
654
  await backend.markAsSent(notificationWithExecutionGitCommitSha.id, true);
583
- });
655
+ }, notificationWithExecutionGitCommitSha.id);
584
656
  }
585
657
  catch (markSentError) {
586
658
  this.logger.error(`Error marking notification ${notificationWithExecutionGitCommitSha.id} as sent: ${markSentError}`);
@@ -591,7 +663,7 @@ class VintaSend {
591
663
  await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, lastAdapterKey, context);
592
664
  }, async (backend) => {
593
665
  await backend.storeAdapterAndContextUsed(notificationWithExecutionGitCommitSha.id, lastAdapterKey, context);
594
- });
666
+ }, notificationWithExecutionGitCommitSha.id);
595
667
  }
596
668
  catch (storeContextError) {
597
669
  this.logger.error(`Error storing adapter and context for notification ${notificationWithExecutionGitCommitSha.id}: ${storeContextError}`);
@@ -630,6 +702,13 @@ class VintaSend {
630
702
  }
631
703
  return String(value);
632
704
  }
705
+ isLikelyDuplicateReplicationConflict(error) {
706
+ const normalizedError = String(error).toLowerCase();
707
+ return (normalizedError.includes('duplicate') ||
708
+ normalizedError.includes('unique') ||
709
+ normalizedError.includes('already exists') ||
710
+ normalizedError.includes('conflict'));
711
+ }
633
712
  /**
634
713
  * Verifies whether a notification is synchronized across all configured backends.
635
714
  *
@@ -710,12 +789,11 @@ class VintaSend {
710
789
  return report;
711
790
  }
712
791
  /**
713
- * Replicates one notification from the primary backend to all additional backends.
792
+ * Worker-facing replication entrypoint.
714
793
  *
715
- * If a notification already exists in an additional backend, it is updated.
716
- * Otherwise, it is created.
794
+ * Reads the notification from the primary backend and upserts into additional backends.
717
795
  */
718
- async replicateNotification(notificationId) {
796
+ async processReplication(notificationId, targetBackendIdentifier) {
719
797
  const primaryNotification = await this.backend.getNotification(notificationId, false);
720
798
  if (!primaryNotification) {
721
799
  throw new Error(`Notification ${String(notificationId)} not found in primary backend`);
@@ -724,27 +802,83 @@ class VintaSend {
724
802
  successes: [],
725
803
  failures: [],
726
804
  };
727
- for (const backend of this.getAdditionalBackends()) {
728
- const backendIdentifier = this.getBackendIdentifier(backend);
805
+ const replicationTargets = this.getAdditionalBackends()
806
+ .map((backend) => {
807
+ return {
808
+ backend,
809
+ backendIdentifier: this.getBackendIdentifier(backend),
810
+ };
811
+ })
812
+ .filter(({ backendIdentifier }) => {
813
+ if (!targetBackendIdentifier) {
814
+ return true;
815
+ }
816
+ return backendIdentifier === targetBackendIdentifier;
817
+ });
818
+ if (targetBackendIdentifier && replicationTargets.length === 0) {
819
+ throw new Error(`Additional backend not found: ${targetBackendIdentifier}`);
820
+ }
821
+ const replicationTaskResults = await Promise.all(replicationTargets.map(async ({ backend, backendIdentifier }) => {
729
822
  try {
823
+ if (typeof backend.applyReplicationSnapshotIfNewer === 'function') {
824
+ const conditionalApplyResult = await backend.applyReplicationSnapshotIfNewer(primaryNotification);
825
+ if (!conditionalApplyResult.applied) {
826
+ this.logger.info(`Skipped replication for notification ${String(notificationId)} on backend ${backendIdentifier} because destination state is newer or equal`);
827
+ }
828
+ return {
829
+ backendIdentifier,
830
+ error: null,
831
+ };
832
+ }
730
833
  const existingNotification = await backend.getNotification(notificationId, false);
731
834
  if (existingNotification) {
732
835
  await backend.persistNotificationUpdate(notificationId, primaryNotification);
733
836
  }
734
837
  else {
735
- await backend.persistNotification(primaryNotification);
838
+ try {
839
+ await backend.persistNotification(primaryNotification);
840
+ }
841
+ catch (createError) {
842
+ if (!this.isLikelyDuplicateReplicationConflict(createError)) {
843
+ throw createError;
844
+ }
845
+ this.logger.warn(`Detected duplicate replication create on backend ${backendIdentifier} for notification ${String(notificationId)}. Retrying as update for idempotency.`);
846
+ await backend.persistNotificationUpdate(notificationId, primaryNotification);
847
+ }
736
848
  }
737
- result.successes.push(backendIdentifier);
849
+ return {
850
+ backendIdentifier,
851
+ error: null,
852
+ };
738
853
  }
739
854
  catch (error) {
740
- result.failures.push({
741
- backend: backendIdentifier,
855
+ return {
856
+ backendIdentifier,
742
857
  error: String(error),
858
+ };
859
+ }
860
+ }));
861
+ for (const taskResult of replicationTaskResults) {
862
+ if (taskResult.error) {
863
+ result.failures.push({
864
+ backend: taskResult.backendIdentifier,
865
+ error: taskResult.error,
743
866
  });
867
+ continue;
744
868
  }
869
+ result.successes.push(taskResult.backendIdentifier);
745
870
  }
746
871
  return result;
747
872
  }
873
+ /**
874
+ * Replicates one notification from the primary backend to all additional backends.
875
+ *
876
+ * If a notification already exists in an additional backend, it is updated.
877
+ * Otherwise, it is created.
878
+ */
879
+ async replicateNotification(notificationId) {
880
+ return this.processReplication(notificationId);
881
+ }
748
882
  /**
749
883
  * Returns a lightweight health snapshot for each configured backend.
750
884
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vintasend",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "main": "dist/index.js",
5
5
  "files": [
6
6
  "dist"