vintasend 0.8.1 → 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,122 @@ 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.
221
+
222
+ ## Filtering and Ordering Notifications
223
+
224
+ Use `filterNotifications` to query notifications with pagination and optional ordering.
225
+
226
+ ```typescript
227
+ const notifications = await vintasend.filterNotifications(
228
+ {
229
+ status: 'PENDING_SEND',
230
+ },
231
+ 1,
232
+ 25,
233
+ {
234
+ field: 'createdAt',
235
+ direction: 'desc',
236
+ },
237
+ );
238
+ ```
239
+
240
+ ### `orderBy` shape
241
+
242
+ ```typescript
243
+ type NotificationOrderBy = {
244
+ field: 'sendAfter' | 'sentAt' | 'readAt' | 'createdAt' | 'updatedAt';
245
+ direction: 'asc' | 'desc';
246
+ };
247
+ ```
248
+
249
+ Examples:
250
+ - `{ field: 'createdAt', direction: 'desc' }`
251
+ - `{ field: 'sendAfter', direction: 'asc' }`
252
+
253
+ ### Checking backend support
254
+
255
+ Use `getBackendSupportedFilterCapabilities()` to detect support gaps per backend.
256
+
257
+ ```typescript
258
+ const capabilities = await vintasend.getBackendSupportedFilterCapabilities();
259
+
260
+ if (!capabilities['orderBy.readAt']) {
261
+ // Fallback to another ordering field
262
+ }
263
+ ```
264
+
265
+ When using multiple backends, you can check capabilities for a specific backend identifier:
266
+
267
+ ```typescript
268
+ const replicaCapabilities = await vintasend.getBackendSupportedFilterCapabilities('replica-backend');
269
+ ```
270
+
271
+ `vintasend-medplum` currently does not support `orderBy.readAt`, and reports `orderBy.readAt: false`.
161
272
 
162
273
  ## Attachment Support
163
274
 
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';
@@ -18,6 +18,12 @@ export type StringFilterLookup = {
18
18
  caseSensitive?: boolean;
19
19
  };
20
20
  export type StringFieldFilter = string | StringFilterLookup;
21
+ export type NotificationOrderByField = 'sendAfter' | 'sentAt' | 'readAt' | 'createdAt' | 'updatedAt';
22
+ export type NotificationOrderDirection = 'asc' | 'desc';
23
+ export type NotificationOrderBy = {
24
+ field: NotificationOrderByField;
25
+ direction: NotificationOrderDirection;
26
+ };
21
27
  /**
22
28
  * Flat dotted key capability map describing which filter features a backend supports.
23
29
  * Use flat dotted keys for logical operators, fields, and negations:
@@ -85,6 +91,11 @@ export declare const DEFAULT_BACKEND_FILTER_CAPABILITIES: {
85
91
  'stringLookups.endsWith': boolean;
86
92
  'stringLookups.includes': boolean;
87
93
  'stringLookups.caseInsensitive': boolean;
94
+ 'orderBy.sendAfter': boolean;
95
+ 'orderBy.sentAt': boolean;
96
+ 'orderBy.readAt': boolean;
97
+ 'orderBy.createdAt': boolean;
98
+ 'orderBy.updatedAt': boolean;
88
99
  };
89
100
  export interface BaseNotificationBackend<Config extends BaseNotificationTypeConfig> {
90
101
  /**
@@ -107,6 +118,16 @@ export interface BaseNotificationBackend<Config extends BaseNotificationTypeConf
107
118
  getNotifications(page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
108
119
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
109
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
+ }>;
110
131
  markAsSent(notificationId: Config['NotificationIdType'], checkIsPending: boolean): Promise<AnyDatabaseNotification<Config>>;
111
132
  markAsFailed(notificationId: Config['NotificationIdType'], checkIsPending: boolean): Promise<AnyDatabaseNotification<Config>>;
112
133
  markAsRead(notificationId: Config['NotificationIdType'], checkIsSent: boolean): Promise<DatabaseNotification<Config>>;
@@ -134,7 +155,7 @@ export interface BaseNotificationBackend<Config extends BaseNotificationTypeConf
134
155
  * @param pageSize - Number of results per page
135
156
  * @returns Matching notifications
136
157
  */
137
- filterNotifications(filter: NotificationFilter<Config>, page: number, pageSize: number): Promise<AnyDatabaseNotification<Config>[]>;
158
+ filterNotifications(filter: NotificationFilter<Config>, page: number, pageSize: number, orderBy?: NotificationOrderBy): Promise<AnyDatabaseNotification<Config>[]>;
138
159
  /**
139
160
  * Get the filter capabilities supported by this backend.
140
161
  * Returns an object with flat dotted keys indicating which filtering features are supported.
@@ -26,6 +26,11 @@ exports.DEFAULT_BACKEND_FILTER_CAPABILITIES = {
26
26
  'stringLookups.endsWith': true,
27
27
  'stringLookups.includes': true,
28
28
  'stringLookups.caseInsensitive': true,
29
+ 'orderBy.sendAfter': true,
30
+ 'orderBy.sentAt': true,
31
+ 'orderBy.readAt': true,
32
+ 'orderBy.createdAt': true,
33
+ 'orderBy.updatedAt': true,
29
34
  };
30
35
  /**
31
36
  * Type guard to check if a filter is a field filter (leaf node).
@@ -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 });
@@ -6,12 +6,14 @@ import type { BaseAttachmentManager } from './attachment-manager/base-attachment
6
6
  import type { BaseGitCommitShaProvider } from './git-commit-sha/base-git-commit-sha-provider';
7
7
  import type { BaseLogger } from './loggers/base-logger';
8
8
  import { type BaseNotificationAdapter } from './notification-adapters/base-notification-adapter';
9
- import { type BaseNotificationBackend, type NotificationFilterFields } from './notification-backends/base-notification-backend';
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;
@@ -145,7 +150,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
145
150
  /**
146
151
  * Filters notifications in the primary backend by default or in a specific backend.
147
152
  */
148
- filterNotifications(filter: NotificationFilterFields<Config>, page: number, pageSize: number, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
153
+ filterNotifications(filter: NotificationFilterFields<Config>, page: number, pageSize: number, orderBy?: NotificationOrderBy, backendIdentifier?: string): Promise<AnyDatabaseNotification<Config>[]>;
149
154
  /**
150
155
  * Returns the effective filter capabilities for the primary backend by default or for a specific backend.
151
156
  */
@@ -171,6 +176,11 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
171
176
  'stringLookups.endsWith': boolean;
172
177
  'stringLookups.includes': boolean;
173
178
  'stringLookups.caseInsensitive': boolean;
179
+ 'orderBy.sendAfter': boolean;
180
+ 'orderBy.sentAt': boolean;
181
+ 'orderBy.readAt': boolean;
182
+ 'orderBy.createdAt': boolean;
183
+ 'orderBy.updatedAt': boolean;
174
184
  }>;
175
185
  /**
176
186
  * Gets a one-off notification by ID.
@@ -191,6 +201,7 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
191
201
  delayedSend(notificationId: Config['NotificationIdType']): Promise<void>;
192
202
  bulkPersistNotifications(notifications: Omit<AnyNotification<Config>, 'id'>[]): Promise<Config['NotificationIdType'][]>;
193
203
  private normalizeValueForSyncComparison;
204
+ private isLikelyDuplicateReplicationConflict;
194
205
  /**
195
206
  * Verifies whether a notification is synchronized across all configured backends.
196
207
  *
@@ -206,6 +217,18 @@ export declare class VintaSend<Config extends BaseNotificationTypeConfig, Adapte
206
217
  }>;
207
218
  discrepancies: string[];
208
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
+ }>;
209
232
  /**
210
233
  * Replicates one notification from the primary backend to all additional backends.
211
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`);
@@ -428,8 +500,8 @@ class VintaSend {
428
500
  /**
429
501
  * Filters notifications in the primary backend by default or in a specific backend.
430
502
  */
431
- async filterNotifications(filter, page, pageSize, backendIdentifier) {
432
- return this.getBackend(backendIdentifier).filterNotifications(filter, page, pageSize);
503
+ async filterNotifications(filter, page, pageSize, orderBy, backendIdentifier) {
504
+ return this.getBackend(backendIdentifier).filterNotifications(filter, page, pageSize, orderBy);
433
505
  }
434
506
  /**
435
507
  * Returns the effective filter capabilities for the primary backend by default or for a specific backend.
@@ -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.1",
3
+ "version": "0.9.0",
4
4
  "main": "dist/index.js",
5
5
  "files": [
6
6
  "dist"