simple-backups 1.0.1 → 1.1.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/LICENSE +21 -21
- package/README.md +17 -4
- package/package.json +33 -33
- package/src/SimpleBackups.js +381 -370
- package/src/index.js +3 -3
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Kartik
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kartik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -89,9 +89,9 @@ Below is a breakdown of the `SimpleBackups` class.
|
|
|
89
89
|
* `new SimpleBackups(Object: options)`: Creates a new SimpleBackups instance with the provided `options`.
|
|
90
90
|
* `options` [`Object`]: Constructor options for this instance.
|
|
91
91
|
* `adapters` [`BackupAdapters`]: Adapter methods used to list, create and delete backups from the upstream storage mechanism.
|
|
92
|
-
* `windows` [`BackupWindow[]`]: Retention windows used to determine
|
|
92
|
+
* `windows` [`BackupWindow[]`]: Retention windows used to determine the backup creation cadence and which backups should be kept.
|
|
93
93
|
* **Note!** the SimpleBackups instance starts automatically when constructed.
|
|
94
|
-
* **Note!** the smallest `interval_ms` from the provided windows is used as the internal tick interval.
|
|
94
|
+
* **Note!** the smallest `interval_ms` from the provided windows is used as the backup creation cadence and internal tick interval. Larger windows retain backups created at that cadence instead of creating additional backups.
|
|
95
95
|
* **Note!** all window `interval_ms` and `limit` values must be positive finite integers.
|
|
96
96
|
|
|
97
97
|
#### SimpleBackups Properties
|
|
@@ -129,7 +129,7 @@ Below is a breakdown of the `SimpleBackups` class.
|
|
|
129
129
|
### BackupWindow Properties
|
|
130
130
|
| Property | Type | Description |
|
|
131
131
|
| :-------- | :------- | :------------------------- |
|
|
132
|
-
| `interval_ms` | `Number` | The
|
|
132
|
+
| `interval_ms` | `Number` | The width of each retention slot in this window in milliseconds. |
|
|
133
133
|
| `limit` | `Number` | The maximum number of backups to keep in this window. |
|
|
134
134
|
|
|
135
135
|
### Backup Properties
|
|
@@ -139,7 +139,11 @@ Below is a breakdown of the `SimpleBackups` class.
|
|
|
139
139
|
| `timestamp` | `Number` | The timestamp at which the backup was created in milliseconds. |
|
|
140
140
|
|
|
141
141
|
## Retention Behavior
|
|
142
|
-
SimpleBackups creates a new backup when no existing backup exists within the smallest configured backup window interval. During each tick, every backup window independently selects
|
|
142
|
+
SimpleBackups creates a new backup when no existing backup exists within the smallest configured backup window interval. During each tick, every backup window independently selects one backup for each populated retention slot and stale backups which are not selected by any window are deleted.
|
|
143
|
+
|
|
144
|
+
Retention slots are fixed and derived only from each backup's timestamp using `Math.floor(timestamp / interval_ms)`. The earliest available backup in a slot is retained because it is closest to the start of that slot. A backup therefore remains in the same slot as time passes, allowing an hourly backup selected by a daily window to remain available for later daily slots instead of being replaced and deleted every hour.
|
|
145
|
+
|
|
146
|
+
Slots are anchored to the Unix epoch. As a result, `day` in the example represents consecutive fixed 24-hour periods rather than local calendar days, and is not affected by time zones or daylight saving time.
|
|
143
147
|
|
|
144
148
|
For example, the following windows will attempt to keep up to 24 hourly backups and up to 30 daily backups.
|
|
145
149
|
|
|
@@ -150,9 +154,18 @@ windows: [
|
|
|
150
154
|
]
|
|
151
155
|
```
|
|
152
156
|
|
|
157
|
+
With uninterrupted hourly backups, this configuration converges to 24 populated hourly slots and 30 populated daily slots (the current slot plus the previous 29 slots). Because one backup can satisfy both windows at the same time, this normally represents 53 unique stored backups rather than 54.
|
|
158
|
+
|
|
153
159
|
* **Note** backups can satisfy multiple windows at the same time.
|
|
154
160
|
* **Note** SimpleBackups can only keep backups that exist and cannot create missed backups for time periods where the process was offline.
|
|
155
161
|
* **Note** backups are selected by timestamp and not by filename or storage order.
|
|
156
162
|
|
|
163
|
+
## Testing
|
|
164
|
+
The test suite uses the built-in Node.js test runner and does not require any additional dependencies.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npm test
|
|
168
|
+
```
|
|
169
|
+
|
|
157
170
|
## License
|
|
158
171
|
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "simple-backups",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Simple yet unopinionated zero-dependency package to easily setup automatic backups for any data source that persists to any storage mechanism.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./src/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"src",
|
|
12
|
-
"README.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"test": "node --test"
|
|
17
|
-
},
|
|
18
|
-
"keywords": [
|
|
19
|
-
"backup",
|
|
20
|
-
"backups",
|
|
21
|
-
"storage",
|
|
22
|
-
"scheduler",
|
|
23
|
-
"nodejs",
|
|
24
|
-
"javascript",
|
|
25
|
-
"s3-bucket",
|
|
26
|
-
"unopinionated",
|
|
27
|
-
"zero-dependency"
|
|
28
|
-
],
|
|
29
|
-
"license": "MIT",
|
|
30
|
-
"engines": {
|
|
31
|
-
"node": ">=20"
|
|
32
|
-
}
|
|
33
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "simple-backups",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Simple yet unopinionated zero-dependency package to easily setup automatic backups for any data source that persists to any storage mechanism.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"backup",
|
|
20
|
+
"backups",
|
|
21
|
+
"storage",
|
|
22
|
+
"scheduler",
|
|
23
|
+
"nodejs",
|
|
24
|
+
"javascript",
|
|
25
|
+
"s3-bucket",
|
|
26
|
+
"unopinionated",
|
|
27
|
+
"zero-dependency"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/SimpleBackups.js
CHANGED
|
@@ -1,370 +1,381 @@
|
|
|
1
|
-
import EventEmitter from 'node:events';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @typedef {Object} Backup
|
|
5
|
-
* @property {string} id The unique identifier of the backup.
|
|
6
|
-
* @property {number} timestamp The timestamp at which the backup was created (in milliseconds).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {Object} BackupWindow
|
|
11
|
-
* @property {number} interval_ms The
|
|
12
|
-
* @property {number} limit The maximum number of backups to keep in this window.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @typedef {Object} BackupAdapters
|
|
17
|
-
* @property {function():Backup[]|Promise<Backup[]>} list This method **must** return an array of all backups from the upstream storage mechanism.
|
|
18
|
-
* @property {function():Backup|Promise<Backup>} create This method **must** create a new backup and persist it to the upstream storage mechanism.
|
|
19
|
-
* @property {function(Backup):boolean|Promise<boolean>} delete This method **must** attempt to delete the specified backups from the upstream storage mechanism and return a boolean indicating whether the operation was successful.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {Object} SimpleBackupsOptions
|
|
24
|
-
* @property {BackupAdapters} adapters The backup adapters to use.
|
|
25
|
-
* @property {BackupWindow[]} windows The backup windows to use.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {'create'|'delete'} SimpleBackupsBackupEvent
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @typedef {'error'} SimpleBackupsErrorEvent
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @typedef {(backup: Backup) => void} SimpleBackupsBackupListener
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @typedef {(error: Error) => void} SimpleBackupsErrorListener
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
export class SimpleBackups extends EventEmitter {
|
|
45
|
-
#destroyed = false;
|
|
46
|
-
#initialized = false;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The options for the SimpleBackups instance.
|
|
50
|
-
* @type {SimpleBackupsOptions}
|
|
51
|
-
*/
|
|
52
|
-
#options;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The intervals for the backup windows.
|
|
56
|
-
* @type {Map<string, ReturnType<typeof setInterval>>}
|
|
57
|
-
*/
|
|
58
|
-
#intervals = new Map();
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The most recently listed backups from the upstream storage mechanism.
|
|
62
|
-
* @type {Backup[]}
|
|
63
|
-
*/
|
|
64
|
-
#backups = [];
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The smallest backup window interval.
|
|
68
|
-
* @type {number}
|
|
69
|
-
*/
|
|
70
|
-
#smallest_interval_ms = Infinity;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Constructs a new SimpleBackups instance.
|
|
74
|
-
* @param {SimpleBackupsOptions} options
|
|
75
|
-
*/
|
|
76
|
-
constructor(options) {
|
|
77
|
-
super();
|
|
78
|
-
this.#options = options;
|
|
79
|
-
|
|
80
|
-
// Enforce valid options
|
|
81
|
-
if (!Array.isArray(this.#options?.windows) || !this.#options?.windows?.length)
|
|
82
|
-
throw new Error('You must specify at least one backup window.');
|
|
83
|
-
if (typeof this.#options?.adapters?.list !== 'function')
|
|
84
|
-
throw new Error('You must specify a backup adapters list method.');
|
|
85
|
-
if (typeof this.#options?.adapters?.create !== 'function')
|
|
86
|
-
throw new Error('You must specify a backup adapters create method.');
|
|
87
|
-
if (typeof this.#options?.adapters?.delete !== 'function')
|
|
88
|
-
throw new Error('You must specify a backup adapters delete method.');
|
|
89
|
-
|
|
90
|
-
// Find the most frequent backup interval
|
|
91
|
-
for (const window of this.#options.windows) {
|
|
92
|
-
if (
|
|
93
|
-
Number.isNaN(window?.limit) ||
|
|
94
|
-
!Number.isFinite(window?.limit) ||
|
|
95
|
-
!Number.isInteger(window?.limit) ||
|
|
96
|
-
window?.limit <= 0
|
|
97
|
-
)
|
|
98
|
-
throw new Error('Backup windows must have a positive finite limit.');
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
Number.isNaN(window?.interval_ms) ||
|
|
102
|
-
!Number.isFinite(window?.interval_ms) ||
|
|
103
|
-
!Number.isInteger(window?.interval_ms) ||
|
|
104
|
-
window?.interval_ms <= 0
|
|
105
|
-
)
|
|
106
|
-
throw new Error('Backup windows must have a positive finite interval_ms.');
|
|
107
|
-
|
|
108
|
-
if (window?.interval_ms <= this.#smallest_interval_ms) this.#smallest_interval_ms = window?.interval_ms;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Create a tick interval with the smallest interval (aka. most precision)
|
|
112
|
-
this.#intervals.set(
|
|
113
|
-
'tick',
|
|
114
|
-
setInterval(() => this.__tick(), this.#smallest_interval_ms),
|
|
115
|
-
);
|
|
116
|
-
setTimeout(() => this.__tick(), 0); // Schedule an immediate tick
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
#tick_in_flight = false;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
* @param {SimpleBackupsBackupEvent
|
|
274
|
-
* @param {SimpleBackupsBackupListener
|
|
275
|
-
* @returns {this}
|
|
276
|
-
*/
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
* @param {
|
|
285
|
-
* @
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
* @param {SimpleBackupsBackupEvent
|
|
295
|
-
* @param {SimpleBackupsBackupListener
|
|
296
|
-
* @returns {this}
|
|
297
|
-
*/
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
* @param {
|
|
306
|
-
* @
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
* @param {SimpleBackupsBackupEvent
|
|
316
|
-
* @param {SimpleBackupsBackupListener
|
|
317
|
-
* @returns {this}
|
|
318
|
-
*/
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
* @param {
|
|
327
|
-
* @
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
* @param {SimpleBackupsBackupEvent
|
|
337
|
-
* @param {Backup
|
|
338
|
-
* @returns {boolean}
|
|
339
|
-
*/
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Backup
|
|
5
|
+
* @property {string} id The unique identifier of the backup.
|
|
6
|
+
* @property {number} timestamp The timestamp at which the backup was created (in milliseconds).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} BackupWindow
|
|
11
|
+
* @property {number} interval_ms The width of each retention slot in this window (in milliseconds).
|
|
12
|
+
* @property {number} limit The maximum number of backups to keep in this window.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} BackupAdapters
|
|
17
|
+
* @property {function():Backup[]|Promise<Backup[]>} list This method **must** return an array of all backups from the upstream storage mechanism.
|
|
18
|
+
* @property {function():Backup|Promise<Backup>} create This method **must** create a new backup and persist it to the upstream storage mechanism.
|
|
19
|
+
* @property {function(Backup):boolean|Promise<boolean>} delete This method **must** attempt to delete the specified backups from the upstream storage mechanism and return a boolean indicating whether the operation was successful.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} SimpleBackupsOptions
|
|
24
|
+
* @property {BackupAdapters} adapters The backup adapters to use.
|
|
25
|
+
* @property {BackupWindow[]} windows The backup windows to use.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {'create'|'delete'} SimpleBackupsBackupEvent
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {'error'} SimpleBackupsErrorEvent
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {(backup: Backup) => void} SimpleBackupsBackupListener
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {(error: Error) => void} SimpleBackupsErrorListener
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export class SimpleBackups extends EventEmitter {
|
|
45
|
+
#destroyed = false;
|
|
46
|
+
#initialized = false;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The options for the SimpleBackups instance.
|
|
50
|
+
* @type {SimpleBackupsOptions}
|
|
51
|
+
*/
|
|
52
|
+
#options;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The intervals for the backup windows.
|
|
56
|
+
* @type {Map<string, ReturnType<typeof setInterval>>}
|
|
57
|
+
*/
|
|
58
|
+
#intervals = new Map();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The most recently listed backups from the upstream storage mechanism.
|
|
62
|
+
* @type {Backup[]}
|
|
63
|
+
*/
|
|
64
|
+
#backups = [];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The smallest backup window interval.
|
|
68
|
+
* @type {number}
|
|
69
|
+
*/
|
|
70
|
+
#smallest_interval_ms = Infinity;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Constructs a new SimpleBackups instance.
|
|
74
|
+
* @param {SimpleBackupsOptions} options
|
|
75
|
+
*/
|
|
76
|
+
constructor(options) {
|
|
77
|
+
super();
|
|
78
|
+
this.#options = options;
|
|
79
|
+
|
|
80
|
+
// Enforce valid options
|
|
81
|
+
if (!Array.isArray(this.#options?.windows) || !this.#options?.windows?.length)
|
|
82
|
+
throw new Error('You must specify at least one backup window.');
|
|
83
|
+
if (typeof this.#options?.adapters?.list !== 'function')
|
|
84
|
+
throw new Error('You must specify a backup adapters list method.');
|
|
85
|
+
if (typeof this.#options?.adapters?.create !== 'function')
|
|
86
|
+
throw new Error('You must specify a backup adapters create method.');
|
|
87
|
+
if (typeof this.#options?.adapters?.delete !== 'function')
|
|
88
|
+
throw new Error('You must specify a backup adapters delete method.');
|
|
89
|
+
|
|
90
|
+
// Find the most frequent backup interval
|
|
91
|
+
for (const window of this.#options.windows) {
|
|
92
|
+
if (
|
|
93
|
+
Number.isNaN(window?.limit) ||
|
|
94
|
+
!Number.isFinite(window?.limit) ||
|
|
95
|
+
!Number.isInteger(window?.limit) ||
|
|
96
|
+
window?.limit <= 0
|
|
97
|
+
)
|
|
98
|
+
throw new Error('Backup windows must have a positive finite limit.');
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
Number.isNaN(window?.interval_ms) ||
|
|
102
|
+
!Number.isFinite(window?.interval_ms) ||
|
|
103
|
+
!Number.isInteger(window?.interval_ms) ||
|
|
104
|
+
window?.interval_ms <= 0
|
|
105
|
+
)
|
|
106
|
+
throw new Error('Backup windows must have a positive finite interval_ms.');
|
|
107
|
+
|
|
108
|
+
if (window?.interval_ms <= this.#smallest_interval_ms) this.#smallest_interval_ms = window?.interval_ms;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Create a tick interval with the smallest interval (aka. most precision)
|
|
112
|
+
this.#intervals.set(
|
|
113
|
+
'tick',
|
|
114
|
+
setInterval(() => this.__tick(), this.#smallest_interval_ms),
|
|
115
|
+
);
|
|
116
|
+
setTimeout(() => this.__tick(), 0); // Schedule an immediate tick
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#tick_in_flight = false;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Determines which backup IDs should be kept by the configured backup windows.
|
|
123
|
+
* @param {number} now The current timestamp (in milliseconds).
|
|
124
|
+
* @returns {Set<string>}
|
|
125
|
+
*/
|
|
126
|
+
_get_keep_backups_ids(now) {
|
|
127
|
+
/** @type {Set<string>} */
|
|
128
|
+
const keep_backups_ids = new Set();
|
|
129
|
+
for (let i = 0; i < this.#options.windows.length; i++) {
|
|
130
|
+
/** @type {Map<number, Backup>} */
|
|
131
|
+
const best_by_slot = new Map();
|
|
132
|
+
const window = this.#options.windows[i];
|
|
133
|
+
const current_slot = Math.floor(now / window.interval_ms); // Anchor slots to timestamps instead of their ever-changing age
|
|
134
|
+
const oldest_slot = current_slot - window.limit + 1;
|
|
135
|
+
|
|
136
|
+
for (let j = 0; j < this.#backups.length; j++) {
|
|
137
|
+
const backup = this.#backups[j];
|
|
138
|
+
if (backup.timestamp > now) continue; // Ignore future backups (this should not even be possible?)
|
|
139
|
+
|
|
140
|
+
const slot = Math.floor(backup.timestamp / window.interval_ms); // A backup always belongs to the same slot as it ages
|
|
141
|
+
if (slot < oldest_slot || slot > current_slot) continue; // Ignore backups outside this window
|
|
142
|
+
|
|
143
|
+
// Backups are sorted from oldest to newest, so the first backup is the closest one to the start of this slot
|
|
144
|
+
if (!best_by_slot.has(slot)) best_by_slot.set(slot, backup);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const [_, backup] of best_by_slot) keep_backups_ids.add(backup.id); // Add the remaining best backup slot candidates to the list of backups to keep
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return keep_backups_ids;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ticks the SimpleBackups instance.
|
|
155
|
+
*/
|
|
156
|
+
async __tick() {
|
|
157
|
+
if (this.#destroyed || this.#tick_in_flight) return;
|
|
158
|
+
this.#tick_in_flight = true;
|
|
159
|
+
try {
|
|
160
|
+
// If we have not initialized yet, then list all available backups from the upstream storage mechanism and cache them in memory
|
|
161
|
+
if (!this.#initialized) {
|
|
162
|
+
try {
|
|
163
|
+
this.#backups = await this.#options.adapters.list();
|
|
164
|
+
if (!Array.isArray(this.#backups))
|
|
165
|
+
throw new Error('Backup adapters list method must return an array of backups.');
|
|
166
|
+
|
|
167
|
+
// Validate the backups
|
|
168
|
+
const seen_ids = new Set();
|
|
169
|
+
for (const backup of this.#backups) {
|
|
170
|
+
if (!backup?.id || typeof backup?.id !== 'string' || seen_ids.has(backup?.id))
|
|
171
|
+
throw new Error(
|
|
172
|
+
'Backup adapters list method must return an array of backups with unique IDs.',
|
|
173
|
+
);
|
|
174
|
+
seen_ids.add(backup.id);
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
typeof backup?.timestamp !== 'number' ||
|
|
178
|
+
!Number.isFinite(backup?.timestamp) ||
|
|
179
|
+
backup?.timestamp < 0
|
|
180
|
+
)
|
|
181
|
+
throw new Error(
|
|
182
|
+
'Backup adapters list method must return an array of backups with finite timestamps.',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.#backups.sort((a, b) => a.timestamp - b.timestamp); // Sort by ascending timestamp (oldest to newest)
|
|
187
|
+
this.#initialized = true;
|
|
188
|
+
if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
|
|
189
|
+
} catch (error) {
|
|
190
|
+
this.emit('error', error); // Emit the error which may have occurred when listing the backups
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let now = Date.now();
|
|
196
|
+
let should_create_backup = true; // Scan all existing backups to determine if we have a backup within the smallest interval slot to determine whether we need to create a new backup
|
|
197
|
+
for (let i = 0; i < this.#backups.length; i++) {
|
|
198
|
+
const backup = this.#backups[i];
|
|
199
|
+
const backup_age = now - backup.timestamp; // Measure the age of the backup (in milliseconds)
|
|
200
|
+
if (backup_age >= 0 && backup_age < this.#smallest_interval_ms) {
|
|
201
|
+
should_create_backup = false; // We already have a backup within the smallest interval, so we don't need to create another one
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (should_create_backup) {
|
|
206
|
+
try {
|
|
207
|
+
const backup = await this.#options.adapters.create(); // Create a new backup
|
|
208
|
+
|
|
209
|
+
let already_exists = false; // Determine if a backup with the same ID already exists
|
|
210
|
+
for (const _backup of this.#backups) {
|
|
211
|
+
if (_backup.id === backup?.id) {
|
|
212
|
+
already_exists = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!backup?.id || typeof backup?.id !== 'string' || already_exists)
|
|
217
|
+
throw new Error('Backup adapters create method must return a backup with a unique ID.');
|
|
218
|
+
if (
|
|
219
|
+
typeof backup.timestamp !== 'number' ||
|
|
220
|
+
!Number.isFinite(backup.timestamp) ||
|
|
221
|
+
backup.timestamp < 0
|
|
222
|
+
)
|
|
223
|
+
throw new Error('Backup adapters create method must return a backup with a finite timestamp.');
|
|
224
|
+
|
|
225
|
+
this.#backups.push(backup); // Add the new backup to the list of backups in cache
|
|
226
|
+
this.#backups.sort((a, b) => a.timestamp - b.timestamp); // Sort by ascending timestamp (oldest to newest)
|
|
227
|
+
now = Date.now(); // Update the current time since we did a potentially asynchronous operation
|
|
228
|
+
this.emit('create', backup); // Emit this backup as being created
|
|
229
|
+
if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.emit('error', error); // Emit the error which may have occurred when creating the backup
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const keep_backups_ids = this._get_keep_backups_ids(now); // Determine which backup IDs to keep
|
|
237
|
+
|
|
238
|
+
const stale_backups = []; // Determine which backups are stale and need to be deleted
|
|
239
|
+
const deleted_backups_ids = new Set(); // Determine which backup IDs have been deleted
|
|
240
|
+
for (let i = 0; i < this.#backups.length; i++) {
|
|
241
|
+
const backup = this.#backups[i];
|
|
242
|
+
if (!keep_backups_ids.has(backup.id)) stale_backups.push(backup);
|
|
243
|
+
}
|
|
244
|
+
for (const backup of stale_backups) {
|
|
245
|
+
try {
|
|
246
|
+
const deleted = await this.#options.adapters.delete(backup); // Attempt to delete the stale backup
|
|
247
|
+
if (deleted) {
|
|
248
|
+
this.emit('delete', backup); // Emit this backup as being deleted
|
|
249
|
+
deleted_backups_ids.add(backup.id); // If the deletion was successful, add the backup ID to the list of deleted backups
|
|
250
|
+
}
|
|
251
|
+
if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.emit('error', error); // Emit the error which may have occurred when deleting the backup
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (deleted_backups_ids.size > 0) {
|
|
257
|
+
const remaining_backups = [];
|
|
258
|
+
for (let i = 0; i < this.#backups.length; i++) {
|
|
259
|
+
const backup = this.#backups[i];
|
|
260
|
+
if (!deleted_backups_ids.has(backup.id)) remaining_backups.push(backup);
|
|
261
|
+
}
|
|
262
|
+
this.#backups = remaining_backups; // Rebuild and overwrite the backups array with the remaining backups
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
this.emit('error', error);
|
|
266
|
+
} finally {
|
|
267
|
+
this.#tick_in_flight = false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @overload
|
|
273
|
+
* @param {SimpleBackupsBackupEvent} event
|
|
274
|
+
* @param {SimpleBackupsBackupListener} listener
|
|
275
|
+
* @returns {this}
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* @overload
|
|
279
|
+
* @param {SimpleBackupsErrorEvent} event
|
|
280
|
+
* @param {SimpleBackupsErrorListener} listener
|
|
281
|
+
* @returns {this}
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
|
|
285
|
+
* @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
|
|
286
|
+
* @returns {this}
|
|
287
|
+
*/
|
|
288
|
+
on(event, listener) {
|
|
289
|
+
return super.on(event, listener);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @overload
|
|
294
|
+
* @param {SimpleBackupsBackupEvent} event
|
|
295
|
+
* @param {SimpleBackupsBackupListener} listener
|
|
296
|
+
* @returns {this}
|
|
297
|
+
*/
|
|
298
|
+
/**
|
|
299
|
+
* @overload
|
|
300
|
+
* @param {SimpleBackupsErrorEvent} event
|
|
301
|
+
* @param {SimpleBackupsErrorListener} listener
|
|
302
|
+
* @returns {this}
|
|
303
|
+
*/
|
|
304
|
+
/**
|
|
305
|
+
* @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
|
|
306
|
+
* @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
|
|
307
|
+
* @returns {this}
|
|
308
|
+
*/
|
|
309
|
+
once(event, listener) {
|
|
310
|
+
return super.once(event, listener);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @overload
|
|
315
|
+
* @param {SimpleBackupsBackupEvent} event
|
|
316
|
+
* @param {SimpleBackupsBackupListener} listener
|
|
317
|
+
* @returns {this}
|
|
318
|
+
*/
|
|
319
|
+
/**
|
|
320
|
+
* @overload
|
|
321
|
+
* @param {SimpleBackupsErrorEvent} event
|
|
322
|
+
* @param {SimpleBackupsErrorListener} listener
|
|
323
|
+
* @returns {this}
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
326
|
+
* @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
|
|
327
|
+
* @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
|
|
328
|
+
* @returns {this}
|
|
329
|
+
*/
|
|
330
|
+
off(event, listener) {
|
|
331
|
+
return super.off(event, listener);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @overload
|
|
336
|
+
* @param {SimpleBackupsBackupEvent} event
|
|
337
|
+
* @param {Backup} backup
|
|
338
|
+
* @returns {boolean}
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* @overload
|
|
342
|
+
* @param {SimpleBackupsErrorEvent} event
|
|
343
|
+
* @param {Error} error
|
|
344
|
+
* @returns {boolean}
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
|
|
348
|
+
* @param {Backup|Error} value
|
|
349
|
+
* @returns {boolean}
|
|
350
|
+
*/
|
|
351
|
+
emit(event, value) {
|
|
352
|
+
return super.emit(event, value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Destroys the SimpleBackups instance and stops all intervals.
|
|
357
|
+
*/
|
|
358
|
+
destroy() {
|
|
359
|
+
if (this.#destroyed) return;
|
|
360
|
+
this.#destroyed = true; // Only destroy once
|
|
361
|
+
|
|
362
|
+
for (const [_, interval] of this.#intervals) clearInterval(interval);
|
|
363
|
+
this.#intervals.clear(); // Clear all intervals
|
|
364
|
+
|
|
365
|
+
super.removeAllListeners(); // Remove all event listeners (which effectively destroys the EventEmitter)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Returns the current list of backups (if in cache).
|
|
370
|
+
*/
|
|
371
|
+
get backups() {
|
|
372
|
+
return [...this.#backups]; // Return a copy of the backups array
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether the SimpleBackups instance has been destroyed.
|
|
377
|
+
*/
|
|
378
|
+
get destroyed() {
|
|
379
|
+
return this.#destroyed;
|
|
380
|
+
}
|
|
381
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { SimpleBackups } from './SimpleBackups.js';
|
|
2
|
-
|
|
3
|
-
export { SimpleBackups };
|
|
1
|
+
import { SimpleBackups } from './SimpleBackups.js';
|
|
2
|
+
|
|
3
|
+
export { SimpleBackups };
|