simple-backups 1.0.0 → 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 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 when backups should be created and which backups should be kept.
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 interval at which this window's backups should be created in milliseconds. |
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 the best backup for each time slot and stale backups which are not selected by any window are deleted.
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.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
+ }
@@ -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 interval at which this window's backups should be created (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
- * Ticks the SimpleBackups instance.
122
- */
123
- async __tick() {
124
- if (this.#destroyed || this.#tick_in_flight) return;
125
- this.#tick_in_flight = true;
126
- try {
127
- // If we have not initialized yet, then list all available backups from the upstream storage mechanism and cache them in memory
128
- if (!this.#initialized) {
129
- try {
130
- this.#backups = await this.#options.adapters.list();
131
- if (!Array.isArray(this.#backups))
132
- throw new Error('Backup adapters list method must return an array of backups.');
133
-
134
- // Validate the backups
135
- const seen_ids = new Set();
136
- for (const backup of this.#backups) {
137
- if (!backup?.id || typeof backup?.id !== 'string' || seen_ids.has(backup?.id))
138
- throw new Error(
139
- 'Backup adapters list method must return an array of backups with unique IDs.',
140
- );
141
- seen_ids.add(backup.id);
142
-
143
- if (
144
- typeof backup?.timestamp !== 'number' ||
145
- !Number.isFinite(backup?.timestamp) ||
146
- backup?.timestamp < 0
147
- )
148
- throw new Error(
149
- 'Backup adapters list method must return an array of backups with finite timestamps.',
150
- );
151
- }
152
-
153
- this.#backups.sort((a, b) => a.timestamp - b.timestamp); // Sort by ascending timestamp (oldest to newest)
154
- this.#initialized = true;
155
- if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
156
- } catch (error) {
157
- this.emit('error', error); // Emit the error which may have occurred when listing the backups
158
- return;
159
- }
160
- }
161
-
162
- let now = Date.now();
163
- 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
164
- for (let i = 0; i < this.#backups.length; i++) {
165
- const backup = this.#backups[i];
166
- const backup_age = now - backup.timestamp; // Measure the age of the backup (in milliseconds)
167
- if (backup_age >= 0 && backup_age < this.#smallest_interval_ms) {
168
- should_create_backup = false; // We already have a backup within the smallest interval, so we don't need to create another one
169
- break;
170
- }
171
- }
172
- if (should_create_backup) {
173
- try {
174
- const backup = await this.#options.adapters.create(); // Create a new backup
175
-
176
- let already_exists = false; // Determine if a backup with the same ID already exists
177
- for (const _backup of this.#backups) {
178
- if (_backup.id === backup?.id) {
179
- already_exists = true;
180
- break;
181
- }
182
- }
183
- if (!backup?.id || typeof backup?.id !== 'string' || already_exists)
184
- throw new Error('Backup adapters create method must return a backup with a unique ID.');
185
- if (
186
- typeof backup.timestamp !== 'number' ||
187
- !Number.isFinite(backup.timestamp) ||
188
- backup.timestamp < 0
189
- )
190
- throw new Error('Backup adapters create method must return a backup with a finite timestamp.');
191
-
192
- this.#backups.push(backup); // Add the new backup to the list of backups in cache
193
- this.#backups.sort((a, b) => a.timestamp - b.timestamp); // Sort by ascending timestamp (oldest to newest)
194
- now = Date.now(); // Update the current time since we did a potentially asynchronous operation
195
- this.emit('create', backup); // Emit this backup as being created
196
- if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
197
- } catch (error) {
198
- this.emit('error', error); // Emit the error which may have occurred when creating the backup
199
- return;
200
- }
201
- }
202
-
203
- const keep_backups_ids = new Set(); // Determine which backup IDs to keep
204
- for (let i = 0; i < this.#options.windows.length; i++) {
205
- const best_by_slot = new Map();
206
- const window = this.#options.windows[i];
207
- for (let j = 0; j < this.#backups.length; j++) {
208
- const backup = this.#backups[j];
209
- const backup_age_ms = now - backup.timestamp;
210
- if (backup_age_ms < 0) continue; // Ignore future backups (this should not even be possible?)
211
-
212
- const slot = Math.floor(backup_age_ms / window.interval_ms); // Calculate the window slot for the backup
213
- if (slot >= window.limit) continue; // Ignore backups outside this window
214
-
215
- const target_timestamp = now - slot * window.interval_ms; // Calculate the target timestamp for this backup's ideal slot
216
- const distance_to_target_ms = Math.abs(backup.timestamp - target_timestamp); // Calculate the distance between the backup's timestamp and the target timestamp
217
-
218
- const candidate = best_by_slot.get(slot); // Measure and track the best or lowest distance to target timestamp for a given slot
219
- if (!candidate || distance_to_target_ms < candidate.distance_to_target_ms) {
220
- best_by_slot.set(slot, { backup, distance_to_target_ms });
221
- }
222
- }
223
-
224
- 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
225
- }
226
-
227
- const stale_backups = []; // Determine which backups are stale and need to be deleted
228
- const deleted_backups_ids = new Set(); // Determine which backup IDs have been deleted
229
- for (let i = 0; i < this.#backups.length; i++) {
230
- const backup = this.#backups[i];
231
- if (!keep_backups_ids.has(backup.id)) stale_backups.push(backup);
232
- }
233
- for (const backup of stale_backups) {
234
- try {
235
- const deleted = await this.#options.adapters.delete(backup); // Attempt to delete the stale backup
236
- if (deleted) {
237
- this.emit('delete', backup); // Emit this backup as being deleted
238
- deleted_backups_ids.add(backup.id); // If the deletion was successful, add the backup ID to the list of deleted backups
239
- }
240
- if (this.#destroyed) return; // If the instance has been destroyed, then we should stop ticking
241
- } catch (error) {
242
- this.emit('error', error); // Emit the error which may have occurred when deleting the backup
243
- }
244
- }
245
- if (deleted_backups_ids.size > 0) {
246
- const remaining_backups = [];
247
- for (let i = 0; i < this.#backups.length; i++) {
248
- const backup = this.#backups[i];
249
- if (!deleted_backups_ids.has(backup.id)) remaining_backups.push(backup);
250
- }
251
- this.#backups = remaining_backups; // Rebuild and overwrite the backups array with the remaining backups
252
- }
253
- } catch (error) {
254
- this.emit('error', error);
255
- } finally {
256
- this.#tick_in_flight = false;
257
- }
258
- }
259
-
260
- /**
261
- * @overload
262
- * @param {SimpleBackupsBackupEvent} event
263
- * @param {SimpleBackupsBackupListener} listener
264
- * @returns {this}
265
- */
266
- /**
267
- * @overload
268
- * @param {SimpleBackupsErrorEvent} event
269
- * @param {SimpleBackupsErrorListener} listener
270
- * @returns {this}
271
- */
272
- /**
273
- * @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
274
- * @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
275
- * @returns {this}
276
- */
277
- on(event, listener) {
278
- return super.on(event, listener);
279
- }
280
-
281
- /**
282
- * @overload
283
- * @param {SimpleBackupsBackupEvent} event
284
- * @param {SimpleBackupsBackupListener} listener
285
- * @returns {this}
286
- */
287
- /**
288
- * @overload
289
- * @param {SimpleBackupsErrorEvent} event
290
- * @param {SimpleBackupsErrorListener} listener
291
- * @returns {this}
292
- */
293
- /**
294
- * @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
295
- * @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
296
- * @returns {this}
297
- */
298
- once(event, listener) {
299
- return super.once(event, listener);
300
- }
301
-
302
- /**
303
- * @overload
304
- * @param {SimpleBackupsBackupEvent} event
305
- * @param {SimpleBackupsBackupListener} listener
306
- * @returns {this}
307
- */
308
- /**
309
- * @overload
310
- * @param {SimpleBackupsErrorEvent} event
311
- * @param {SimpleBackupsErrorListener} listener
312
- * @returns {this}
313
- */
314
- /**
315
- * @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
316
- * @param {SimpleBackupsBackupListener|SimpleBackupsErrorListener} listener
317
- * @returns {this}
318
- */
319
- off(event, listener) {
320
- return super.off(event, listener);
321
- }
322
-
323
- /**
324
- * @overload
325
- * @param {SimpleBackupsBackupEvent} event
326
- * @param {Backup} backup
327
- * @returns {boolean}
328
- */
329
- /**
330
- * @overload
331
- * @param {SimpleBackupsErrorEvent} event
332
- * @param {Error} error
333
- * @returns {boolean}
334
- */
335
- /**
336
- * @param {SimpleBackupsBackupEvent|SimpleBackupsErrorEvent} event
337
- * @param {Backup|Error} value
338
- * @returns {boolean}
339
- */
340
- emit(event, value) {
341
- return super.emit(event, value);
342
- }
343
-
344
- /**
345
- * Destroys the SimpleBackups instance and stops all intervals.
346
- */
347
- destroy() {
348
- if (this.#destroyed) return;
349
- this.#destroyed = true; // Only destroy once
350
-
351
- for (const [_, interval] of this.#intervals) clearInterval(interval);
352
- this.#intervals.clear(); // Clear all intervals
353
-
354
- super.removeAllListeners(); // Remove all event listeners (which effectively destroys the EventEmitter)
355
- }
356
-
357
- /**
358
- * Returns the current list of backups (if in cache).
359
- */
360
- get backups() {
361
- return [...this.#backups]; // Return a copy of the backups array
362
- }
363
-
364
- /**
365
- * Whether the SimpleBackups instance has been destroyed.
366
- */
367
- get destroyed() {
368
- return this.#destroyed;
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';
2
-
3
- export { SimpleBackups };
1
+ import { SimpleBackups } from './SimpleBackups.js';
2
+
3
+ export { SimpleBackups };