@lazyapps/readmodel-backup-mongodb 0.0.0-init.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.
Files changed (2) hide show
  1. package/index.js +232 -0
  2. package/package.json +42 -0
package/index.js ADDED
@@ -0,0 +1,232 @@
1
+ import { getLogger } from '@lazyapps/logger';
2
+
3
+ const generateBackupId = (readModelName) =>
4
+ `backup_${Date.now()}_${readModelName}`;
5
+
6
+ const parseMaxAge = (maxAge) => {
7
+ const match = maxAge.match(/^(\d+)([dhm])$/);
8
+ if (!match) return 0;
9
+ const value = parseInt(match[1], 10);
10
+ const unit = match[2];
11
+ if (unit === 'd') return value * 24 * 60 * 60 * 1000;
12
+ if (unit === 'h') return value * 60 * 60 * 1000;
13
+ if (unit === 'm') return value * 60 * 1000;
14
+ return 0;
15
+ };
16
+
17
+ export const mongoBackup =
18
+ ({ metadataCollection = 'admin.backups' } = {}) =>
19
+ (storage) => ({
20
+ createBackup: (correlationId, readModelName, collectionNames) => {
21
+ const log = getLogger('RM/Backup', correlationId);
22
+ const backupId = generateBackupId(readModelName);
23
+ const timestamp = Date.now();
24
+
25
+ log.debug(
26
+ `Creating backup ${backupId} for ${readModelName} (collections: ${JSON.stringify(collectionNames)})`,
27
+ );
28
+
29
+ return collectionNames
30
+ .reduce(
31
+ (chain, colName) =>
32
+ chain.then(() =>
33
+ storage.copyCollection(
34
+ correlationId,
35
+ colName,
36
+ `${backupId}_${colName}`,
37
+ ),
38
+ ),
39
+ Promise.resolve(),
40
+ )
41
+ .then(() =>
42
+ storage
43
+ .perRequest(correlationId)
44
+ .find('readmodel.state', { name: readModelName })
45
+ .toArray(),
46
+ )
47
+ .then((docs) => {
48
+ const eventTimestamp = docs[0]?.lastProjectedEventTimestamp || 0;
49
+ const metadata = {
50
+ backupId,
51
+ readModelName,
52
+ timestamp,
53
+ eventTimestamp,
54
+ collections: collectionNames,
55
+ };
56
+ return storage
57
+ .perRequest(correlationId)
58
+ .insertOne(metadataCollection, metadata)
59
+ .then(() => {
60
+ log.debug(`Backup ${backupId} created successfully`);
61
+ return { backupId, timestamp, eventTimestamp };
62
+ });
63
+ });
64
+ },
65
+
66
+ listBackups: (readModelName) =>
67
+ storage
68
+ .perRequest('backup')
69
+ .find(metadataCollection, { readModelName })
70
+ .sort({ timestamp: -1 })
71
+ .toArray()
72
+ .then((docs) => docs.map(({ _id, ...rest }) => rest)),
73
+
74
+ restoreBackup: (correlationId, readModelName, backupId) => {
75
+ const log = getLogger('RM/Backup', correlationId);
76
+ log.debug(`Restoring backup ${backupId} for ${readModelName}`);
77
+
78
+ return storage
79
+ .perRequest(correlationId)
80
+ .find(metadataCollection, { backupId })
81
+ .toArray()
82
+ .then((docs) => {
83
+ if (!docs.length)
84
+ return Promise.reject(new Error(`Backup ${backupId} not found`));
85
+ return docs[0];
86
+ })
87
+ .then((metadata) =>
88
+ metadata.collections
89
+ .reduce(
90
+ (chain, colName) =>
91
+ chain.then(() =>
92
+ storage
93
+ .dropCollection(correlationId, colName)
94
+ .then(() =>
95
+ storage.copyCollection(
96
+ correlationId,
97
+ `${backupId}_${colName}`,
98
+ colName,
99
+ ),
100
+ ),
101
+ ),
102
+ Promise.resolve(),
103
+ )
104
+ .then(() =>
105
+ storage.updateLastProjectedEventTimestamps(
106
+ correlationId,
107
+ [readModelName],
108
+ metadata.eventTimestamp,
109
+ ),
110
+ )
111
+ .then(() => {
112
+ log.debug(`Backup ${backupId} restored successfully`);
113
+ }),
114
+ );
115
+ },
116
+
117
+ deleteBackup: (correlationId, backupId) => {
118
+ const log = getLogger('RM/Backup', correlationId);
119
+ log.debug(`Deleting backup ${backupId}`);
120
+
121
+ return storage
122
+ .perRequest(correlationId)
123
+ .find(metadataCollection, { backupId })
124
+ .toArray()
125
+ .then((docs) => {
126
+ if (!docs.length) return Promise.resolve();
127
+ const metadata = docs[0];
128
+ return metadata.collections
129
+ .reduce(
130
+ (chain, colName) =>
131
+ chain.then(() =>
132
+ storage.dropCollection(
133
+ correlationId,
134
+ `${backupId}_${colName}`,
135
+ ),
136
+ ),
137
+ Promise.resolve(),
138
+ )
139
+ .then(() =>
140
+ storage
141
+ .perRequest(correlationId)
142
+ .deleteOne(metadataCollection, { backupId }),
143
+ )
144
+ .then(() => {
145
+ log.debug(`Backup ${backupId} deleted`);
146
+ });
147
+ });
148
+ },
149
+
150
+ clearCollections: (correlationId, readModelName, collectionNames) => {
151
+ const log = getLogger('RM/Backup', correlationId);
152
+ log.debug(
153
+ `Clearing collections for ${readModelName}: ${JSON.stringify(collectionNames)}`,
154
+ );
155
+
156
+ return collectionNames
157
+ .reduce(
158
+ (chain, colName) =>
159
+ chain.then(() => storage.dropCollection(correlationId, colName)),
160
+ Promise.resolve(),
161
+ )
162
+ .then(() =>
163
+ storage.updateLastProjectedEventTimestamps(
164
+ correlationId,
165
+ [readModelName],
166
+ 0,
167
+ ),
168
+ )
169
+ .then(() => {
170
+ log.debug(`Collections cleared for ${readModelName}`);
171
+ });
172
+ },
173
+
174
+ cleanupBackups: (readModelName, retentionPolicy) => {
175
+ const log = getLogger('RM/Backup', 'cleanup');
176
+
177
+ return storage
178
+ .perRequest('cleanup')
179
+ .find(metadataCollection, { readModelName })
180
+ .sort({ timestamp: -1 })
181
+ .toArray()
182
+ .then((backups) => {
183
+ let toDelete = [];
184
+
185
+ if (
186
+ retentionPolicy.maxCount &&
187
+ backups.length > retentionPolicy.maxCount
188
+ ) {
189
+ toDelete = backups.slice(retentionPolicy.maxCount);
190
+ }
191
+
192
+ if (retentionPolicy.maxAge) {
193
+ const cutoff = Date.now() - parseMaxAge(retentionPolicy.maxAge);
194
+ const aged = backups.filter((b) => b.timestamp < cutoff);
195
+ toDelete = [...new Set([...toDelete, ...aged])];
196
+ }
197
+
198
+ if (!toDelete.length) return Promise.resolve();
199
+
200
+ log.debug(
201
+ `Cleaning up ${toDelete.length} old backups for ${readModelName}`,
202
+ );
203
+
204
+ return toDelete.reduce(
205
+ (chain, backup) =>
206
+ chain.then(() =>
207
+ backup.collections
208
+ .reduce(
209
+ (innerChain, colName) =>
210
+ innerChain.then(() =>
211
+ storage.dropCollection(
212
+ 'cleanup',
213
+ `${backup.backupId}_${colName}`,
214
+ ),
215
+ ),
216
+ Promise.resolve(),
217
+ )
218
+ .then(() =>
219
+ storage
220
+ .perRequest('cleanup')
221
+ .deleteOne(metadataCollection, {
222
+ backupId: backup.backupId,
223
+ }),
224
+ ),
225
+ ),
226
+ Promise.resolve(),
227
+ );
228
+ });
229
+ },
230
+ });
231
+
232
+ export const __testing__ = { generateBackupId, parseMaxAge };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@lazyapps/readmodel-backup-mongodb",
3
+ "version": "0.0.0-init.0",
4
+ "description": "MongoDB-based backup and restore for LazyApps read models",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js"
8
+ ],
9
+ "scripts": {
10
+ "test": "vitest"
11
+ },
12
+ "keywords": [
13
+ "event-sourcing",
14
+ "cqrs",
15
+ "lazyapps",
16
+ "read-models",
17
+ "backup",
18
+ "mongodb"
19
+ ],
20
+ "author": "Oliver Sturm",
21
+ "license": "ISC",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/oliversturm/lazyapps-libs.git",
25
+ "directory": "packages/readmodel-backup-mongodb"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/oliversturm/lazyapps-libs/issues"
29
+ },
30
+ "homepage": "https://github.com/oliversturm/lazyapps-libs/tree/main/packages/readmodel-backup-mongodb#readme",
31
+ "engines": {
32
+ "node": ">=18.20.3 || >=20.18.0"
33
+ },
34
+ "devDependencies": {
35
+ "eslint": "^8.46.0",
36
+ "vitest": "^4.0.18"
37
+ },
38
+ "dependencies": {
39
+ "@lazyapps/logger": "workspace:^"
40
+ },
41
+ "type": "module"
42
+ }