Skip to content

Commit e54c26c

Browse files
authored
Merge pull request #5 from vintasoftware/feat/additional-backends
Additional backends
2 parents 2595650 + 1c8fb68 commit e54c26c

16 files changed

Lines changed: 2570 additions & 56 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## Version 0.8.1
4+
5+
* **Multi-backend support added to VintaSend**:
6+
* Added support for configuring one primary backend plus optional additional backends.
7+
* Implemented primary-first write replication with best-effort propagation to additional backends.
8+
* Added backend-targeted read operations with optional `backendIdentifier` parameters.
9+
* Added backend management operations: `verifyNotificationSync`, `replicateNotification`, and `getBackendSyncStats`.
10+
* Enhanced `migrateToBackend` with optional source backend selection.
11+
* **Documentation and examples**:
12+
* Added multi-backend configuration section to README.
13+
* Added `MULTI_BACKEND_MIGRATION.md` migration guide.
14+
* Added `src/examples/multi-backend-example.ts` with setup, read-routing, and management operation examples.
15+
316
## Version 0.7.1
417

518
* Add `renderEmailTemplateFromContent` method to the VintaSend service, so users can preview older notifications by providing the template content at the time.

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,59 @@ export function sendWelcomeEmail(userId: number) {
106106
}
107107
```
108108

109+
## Multi-Backend Configuration
110+
111+
VintaSend supports configuring multiple backends for redundancy, data distribution, and migration use cases.
112+
113+
### Basic Setup
114+
115+
```typescript
116+
import { VintaSendFactory } from 'vintasend';
117+
118+
const vintasend = new VintaSendFactory<NotificationTypeConfig>().create({
119+
adapters,
120+
backend: primaryBackend,
121+
additionalBackends: [replicaBackend],
122+
logger,
123+
contextGeneratorsMap,
124+
});
125+
```
126+
127+
### How It Works
128+
129+
- **Writes**: VintaSend writes to the primary backend first, then replicates to additional backends on a best-effort basis.
130+
- **Reads**: Read methods use the primary backend by default, but support optional backend targeting by identifier.
131+
132+
```typescript
133+
// Read from primary backend (default)
134+
const notification = await vintasend.getNotification(notificationId);
135+
136+
// Read from a specific backend
137+
const notificationFromReplica = await vintasend.getNotification(
138+
notificationId,
139+
false,
140+
'replica-backend',
141+
);
142+
```
143+
144+
### Backend Management Operations
145+
146+
```typescript
147+
const report = await vintasend.verifyNotificationSync(notificationId);
148+
149+
if (!report.synced) {
150+
await vintasend.replicateNotification(notificationId);
151+
}
152+
153+
const backendStats = await vintasend.getBackendSyncStats();
154+
```
155+
156+
### Failure Handling
157+
158+
- Primary backend failures fail the operation.
159+
- Additional backend replication failures are logged and do not fail the primary operation.
160+
- This keeps primary workflows available while still enabling redundancy.
161+
109162
## Attachment Support
110163

111164
VintaSend supports file attachments for notifications with an extensible architecture that allows you to choose your preferred storage backend.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vintasend",
3-
"version": "0.7.1",
3+
"version": "0.8.1",
44
"main": "dist/index.js",
55
"files": [
66
"dist"
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { VintaSendFactory } from '../../index';
2+
import type { DatabaseNotification, NotificationInput } from '../../types/notification';
3+
import type { BaseLogger } from '../loggers/base-logger';
4+
import type { BaseNotificationAdapter } from '../notification-adapters/base-notification-adapter';
5+
import type { BaseNotificationBackend } from '../notification-backends/base-notification-backend';
6+
import type { BaseEmailTemplateRenderer } from '../notification-template-renderers/base-email-template-renderer';
7+
8+
type ContextGenerators = {
9+
testContext: {
10+
generate: (params: { userId: string }) => Promise<{ userId: string }>;
11+
};
12+
};
13+
14+
type Config = {
15+
ContextMap: ContextGenerators;
16+
NotificationIdType: string;
17+
UserIdType: string;
18+
};
19+
20+
type MockBackend = jest.Mocked<BaseNotificationBackend<Config>> & {
21+
injectLogger: jest.Mock;
22+
injectAttachmentManager: jest.Mock;
23+
getBackendIdentifier: jest.Mock<string, []>;
24+
};
25+
26+
const templateRenderer: jest.Mocked<BaseEmailTemplateRenderer<Config>> = {
27+
render: jest.fn(),
28+
renderFromTemplateContent: jest.fn(),
29+
};
30+
31+
const logger: jest.Mocked<BaseLogger> = {
32+
info: jest.fn(),
33+
error: jest.fn(),
34+
warn: jest.fn(),
35+
};
36+
37+
// biome-ignore lint/suspicious/noExplicitAny: test adapter mock
38+
const adapter: jest.Mocked<BaseNotificationAdapter<any, Config>> = {
39+
notificationType: 'EMAIL',
40+
key: 'adapter-1',
41+
enqueueNotifications: false,
42+
send: jest.fn(),
43+
injectBackend: jest.fn(),
44+
injectLogger: jest.fn(),
45+
backend: null,
46+
templateRenderer,
47+
logger,
48+
supportsAttachments: false,
49+
// biome-ignore lint/suspicious/noExplicitAny: test-only cast
50+
} as any;
51+
52+
const contextGenerators: ContextGenerators = {
53+
testContext: {
54+
generate: jest.fn(),
55+
},
56+
};
57+
58+
function createMockBackend(identifier: string): MockBackend {
59+
return {
60+
persistNotification: jest.fn(),
61+
persistNotificationUpdate: jest.fn(),
62+
getAllFutureNotifications: jest.fn(),
63+
getAllFutureNotificationsFromUser: jest.fn(),
64+
getFutureNotificationsFromUser: jest.fn(),
65+
getFutureNotifications: jest.fn(),
66+
getAllPendingNotifications: jest.fn(),
67+
getPendingNotifications: jest.fn(),
68+
getNotification: jest.fn(),
69+
markAsRead: jest.fn(),
70+
filterAllInAppUnreadNotifications: jest.fn(),
71+
cancelNotification: jest.fn(),
72+
markAsSent: jest.fn(),
73+
markAsFailed: jest.fn(),
74+
storeAdapterAndContextUsed: jest.fn(),
75+
getUserEmailFromNotification: jest.fn(),
76+
filterInAppUnreadNotifications: jest.fn(),
77+
filterNotifications: jest.fn(),
78+
bulkPersistNotifications: jest.fn(),
79+
getAllNotifications: jest.fn(),
80+
getNotifications: jest.fn(),
81+
persistOneOffNotification: jest.fn(),
82+
persistOneOffNotificationUpdate: jest.fn(),
83+
getOneOffNotification: jest.fn(),
84+
getAllOneOffNotifications: jest.fn(),
85+
getOneOffNotifications: jest.fn(),
86+
storeAttachmentFileRecord: jest.fn(),
87+
getAttachmentFileRecord: jest.fn(),
88+
getAttachmentFile: jest.fn(),
89+
findAttachmentFileByChecksum: jest.fn(),
90+
deleteAttachmentFile: jest.fn(),
91+
getOrphanedAttachmentFiles: jest.fn(),
92+
getAttachments: jest.fn(),
93+
deleteNotificationAttachment: jest.fn(),
94+
injectLogger: jest.fn(),
95+
injectAttachmentManager: jest.fn(),
96+
getBackendIdentifier: jest.fn(() => identifier),
97+
} as unknown as MockBackend;
98+
}
99+
100+
describe('VintaSend multi-backend error handling (Phase 4)', () => {
101+
const createNotificationInput: Omit<NotificationInput<Config>, 'id'> = {
102+
userId: 'user-1',
103+
notificationType: 'EMAIL',
104+
title: 'Title',
105+
bodyTemplate: 'body',
106+
contextName: 'testContext',
107+
contextParameters: { userId: 'user-1' },
108+
sendAfter: new Date(Date.now() + 60_000),
109+
subjectTemplate: 'subject',
110+
extraParams: null,
111+
};
112+
113+
const databaseNotification = {
114+
...createNotificationInput,
115+
id: 'notif-1',
116+
status: 'PENDING_SEND',
117+
contextUsed: null,
118+
adapterUsed: null,
119+
sentAt: null,
120+
readAt: null,
121+
gitCommitSha: null,
122+
} as DatabaseNotification<Config>;
123+
124+
beforeEach(() => {
125+
jest.clearAllMocks();
126+
contextGenerators.testContext.generate = jest.fn().mockResolvedValue({ userId: 'user-1' });
127+
});
128+
129+
it('continues when one additional backend replication fails', async () => {
130+
const primaryBackend = createMockBackend('primary');
131+
const failingReplica = createMockBackend('replica-a');
132+
const healthyReplica = createMockBackend('replica-b');
133+
134+
primaryBackend.persistNotification.mockResolvedValue(databaseNotification);
135+
failingReplica.persistNotification.mockRejectedValue(new Error('replication failed'));
136+
healthyReplica.persistNotification.mockResolvedValue(databaseNotification);
137+
138+
const service = new VintaSendFactory<Config>().create({
139+
adapters: [adapter],
140+
backend: primaryBackend,
141+
additionalBackends: [failingReplica, healthyReplica],
142+
logger,
143+
contextGeneratorsMap: contextGenerators,
144+
});
145+
146+
const result = await service.createNotification(createNotificationInput);
147+
148+
expect(result.id).toBe('notif-1');
149+
expect(healthyReplica.persistNotification).toHaveBeenCalledWith({
150+
...createNotificationInput,
151+
id: 'notif-1',
152+
});
153+
expect(logger.error).toHaveBeenCalledWith(
154+
expect.stringContaining('Failed to replicate createNotification to backend replica-a'),
155+
);
156+
});
157+
158+
it('continues processing send path when additional backend markAsSent fails', async () => {
159+
const primaryBackend = createMockBackend('primary');
160+
const failingReplica = createMockBackend('replica-a');
161+
162+
adapter.send.mockResolvedValue();
163+
164+
primaryBackend.markAsSent.mockResolvedValue(databaseNotification);
165+
failingReplica.markAsSent.mockRejectedValue(new Error('markAsSent failed'));
166+
167+
primaryBackend.storeAdapterAndContextUsed.mockResolvedValue();
168+
failingReplica.storeAdapterAndContextUsed.mockResolvedValue();
169+
170+
const service = new VintaSendFactory<Config>().create({
171+
adapters: [adapter],
172+
backend: primaryBackend,
173+
additionalBackends: [failingReplica],
174+
logger,
175+
contextGeneratorsMap: contextGenerators,
176+
});
177+
178+
await expect(service.send(databaseNotification)).resolves.toBeUndefined();
179+
180+
expect(primaryBackend.markAsSent).toHaveBeenCalledWith('notif-1', true);
181+
expect(failingReplica.markAsSent).toHaveBeenCalledWith('notif-1', true);
182+
expect(primaryBackend.storeAdapterAndContextUsed).toHaveBeenCalled();
183+
expect(failingReplica.storeAdapterAndContextUsed).toHaveBeenCalled();
184+
expect(logger.error).toHaveBeenCalledWith(
185+
expect.stringContaining('Failed to replicate markAsSent to backend replica-a'),
186+
);
187+
});
188+
189+
it('logs all additional backend failures without failing operation', async () => {
190+
const primaryBackend = createMockBackend('primary');
191+
const replicaA = createMockBackend('replica-a');
192+
const replicaB = createMockBackend('replica-b');
193+
194+
primaryBackend.markAsRead.mockResolvedValue({
195+
...databaseNotification,
196+
status: 'SENT',
197+
readAt: new Date(),
198+
});
199+
replicaA.markAsRead.mockRejectedValue(new Error('replica A fail'));
200+
replicaB.markAsRead.mockRejectedValue(new Error('replica B fail'));
201+
202+
const service = new VintaSendFactory<Config>().create({
203+
adapters: [adapter],
204+
backend: primaryBackend,
205+
additionalBackends: [replicaA, replicaB],
206+
logger,
207+
contextGeneratorsMap: contextGenerators,
208+
});
209+
210+
await expect(service.markRead('notif-1')).resolves.toMatchObject({ id: 'notif-1' });
211+
212+
expect(logger.error).toHaveBeenCalledWith(
213+
expect.stringContaining('Failed to replicate markRead to backend replica-a'),
214+
);
215+
expect(logger.error).toHaveBeenCalledWith(
216+
expect.stringContaining('Failed to replicate markRead to backend replica-b'),
217+
);
218+
});
219+
});

0 commit comments

Comments
 (0)