vintasend 0.8.2 → 0.9.1
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 +63 -3
- package/dist/index.d.ts +1 -0
- package/dist/services/notification-backends/base-notification-backend.d.ts +10 -0
- package/dist/services/notification-queue-service/base-notification-replication-queue-service.d.ts +4 -0
- package/dist/services/notification-queue-service/base-notification-replication-queue-service.js +2 -0
- package/dist/services/notification-service.d.ts +20 -2
- package/dist/services/notification-service.js +168 -34
- package/dist/services/notification-template-renderers/base-text-notification-template-renderer.d.ts +21 -0
- package/dist/services/notification-template-renderers/base-text-notification-template-renderer.js +2 -0
- package/package.json +7 -8
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
|
|
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
|
-
-
|
|
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
|
|
|
@@ -527,7 +587,7 @@ VintaSend has many backend, adapter, and template renderer implementations. If y
|
|
|
527
587
|
|
|
528
588
|
##### Backends
|
|
529
589
|
|
|
530
|
-
* **[vintasend-prisma](https://github.com/vintasoftware/vintasend-prisma/)**: Uses Prisma Client to manage the notifications in the database.
|
|
590
|
+
* **[vintasend-prisma](https://github.com/vintasoftware/vintasend-ts-prisma/)**: Uses Prisma Client to manage the notifications in the database.
|
|
531
591
|
* **[vintasend-medplum](https://github.com/vintasoftware/vintasend-medplum/)**: Uses Medplum FHIR resources (Communication, Binary, Media) to manage notifications in healthcare applications.
|
|
532
592
|
|
|
533
593
|
##### Adapters
|
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>>;
|
package/dist/services/notification-queue-service/base-notification-replication-queue-service.d.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
*
|
|
792
|
+
* Worker-facing replication entrypoint.
|
|
714
793
|
*
|
|
715
|
-
*
|
|
716
|
-
* Otherwise, it is created.
|
|
794
|
+
* Reads the notification from the primary backend and upserts into additional backends.
|
|
717
795
|
*/
|
|
718
|
-
async
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
849
|
+
return {
|
|
850
|
+
backendIdentifier,
|
|
851
|
+
error: null,
|
|
852
|
+
};
|
|
738
853
|
}
|
|
739
854
|
catch (error) {
|
|
740
|
-
|
|
741
|
-
|
|
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/dist/services/notification-template-renderers/base-text-notification-template-renderer.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer';
|
|
2
|
+
import type { JsonObject } from '../../types/json-values';
|
|
3
|
+
import type { AnyNotification } from '../../types/notification';
|
|
4
|
+
import type { BaseNotificationTypeConfig } from '../../types/notification-type-config';
|
|
5
|
+
import type { BaseLogger } from '../loggers/base-logger';
|
|
6
|
+
import type { BaseNotificationTemplateRenderer } from './base-notification-template-renderer';
|
|
7
|
+
export type Attachment = File | Buffer | string;
|
|
8
|
+
export type TextNotificationTemplate = {
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
export type TextNotificationTemplateContent = {
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
export interface BaseTextNotificationTemplateRenderer<Config extends BaseNotificationTypeConfig> extends BaseNotificationTemplateRenderer<Config, TextNotificationTemplate> {
|
|
15
|
+
render(notification: AnyNotification<Config>, context: JsonObject): Promise<TextNotificationTemplate>;
|
|
16
|
+
renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: TextNotificationTemplateContent, context: JsonObject): Promise<TextNotificationTemplate>;
|
|
17
|
+
/**
|
|
18
|
+
* Inject logger into the template renderer (optional - called by VintaSend when logger exists)
|
|
19
|
+
*/
|
|
20
|
+
injectLogger?(logger: BaseLogger): void;
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vintasend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"check": "biome check --write .",
|
|
14
14
|
"build": "tsc",
|
|
15
15
|
"prepublishOnly": "npm run build",
|
|
16
|
-
"test": "
|
|
17
|
-
"test:watch": "
|
|
18
|
-
"test:coverage": "
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"test:coverage": "vitest run --coverage",
|
|
19
19
|
"test:implementations:local": "node scripts/test-implementations-local.js",
|
|
20
20
|
"release:bump": "node scripts/release-bump.js",
|
|
21
21
|
"release:bump:patch": "node scripts/release-bump.js --bump=patch",
|
|
@@ -26,12 +26,11 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@biomejs/biome": "^2.3.11",
|
|
29
|
-
"@types/jest": "^30.0.0",
|
|
30
29
|
"@types/mime-types": "^3.0.1",
|
|
31
30
|
"@types/node": "^25.0.8",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
31
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"vitest": "4.0.18"
|
|
35
34
|
},
|
|
36
35
|
"dependencies": {
|
|
37
36
|
"mime-types": "^3.0.2"
|