outlet-orm 5.5.2 → 6.0.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * BackupScheduler
3
+ *
4
+ * Wraps BackupManager to provide simple programmatic scheduling of backups
5
+ * using Node.js timers (no external cron dependency).
6
+ *
7
+ * Usage example:
8
+ *
9
+ * const { BackupScheduler } = require('outlet-orm');
10
+ *
11
+ * const scheduler = new BackupScheduler(db, {
12
+ * backupPath: './database/backups'
13
+ * });
14
+ *
15
+ * // Full backup every 24 h
16
+ * scheduler.schedule('full', { intervalMs: 24 * 60 * 60 * 1000 });
17
+ *
18
+ * // Partial backup (users + orders) every hour
19
+ * scheduler.schedule('partial', {
20
+ * intervalMs : 60 * 60 * 1000,
21
+ * tables : ['users', 'orders'],
22
+ * });
23
+ *
24
+ * // Transaction-journal every 15 minutes with auto-flush
25
+ * scheduler.schedule('journal', {
26
+ * intervalMs: 15 * 60 * 1000,
27
+ * flush: true,
28
+ * });
29
+ *
30
+ * // Stop all scheduled jobs
31
+ * scheduler.stopAll();
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const BackupManager = require('./BackupManager');
37
+
38
+ class BackupScheduler {
39
+ /**
40
+ * @param {import('../DatabaseConnection')} connection
41
+ * @param {object} [options]
42
+ * @param {string} [options.backupPath] Forwarded to BackupManager
43
+ */
44
+ constructor(connection, options = {}) {
45
+ this.manager = new BackupManager(connection, options);
46
+ /** @type {Map<string, NodeJS.Timeout>} */
47
+ this._jobs = new Map();
48
+ }
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // Public API
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Schedule a recurring backup.
56
+ *
57
+ * @param {'full'|'partial'|'journal'} type
58
+ * @param {object} config
59
+ * @param {number} config.intervalMs Interval in milliseconds between backups
60
+ * @param {boolean} [config.runNow=false] Execute once immediately before scheduling
61
+ * @param {string[]} [config.tables] Required when type is 'partial'
62
+ * @param {string} [config.format] 'sql' | 'json' (for full/partial)
63
+ * @param {boolean} [config.flush] Flush query log after each journal backup
64
+ * @param {Function} [config.onSuccess] Callback(filePath) after a successful backup
65
+ * @param {Function} [config.onError] Callback(error) on backup failure
66
+ * @param {string} [config.name] Optional job identifier (defaults to type)
67
+ * @returns {string} Job name
68
+ */
69
+ schedule(type, config) {
70
+ if (!['full', 'partial', 'journal'].includes(type)) {
71
+ throw new Error(`BackupScheduler: invalid backup type "${type}". Use 'full', 'partial', or 'journal'.`);
72
+ }
73
+ if (!config.intervalMs || config.intervalMs < 1000) {
74
+ throw new Error('BackupScheduler: intervalMs must be >= 1000 (1 second)');
75
+ }
76
+ if (type === 'partial' && (!Array.isArray(config.tables) || config.tables.length === 0)) {
77
+ throw new Error("BackupScheduler: 'tables' array is required for partial backups");
78
+ }
79
+
80
+ const name = config.name || `${type}_${Date.now()}`;
81
+
82
+ if (this._jobs.has(name)) {
83
+ this.stop(name);
84
+ }
85
+
86
+ const run = () => this._execute(type, config);
87
+
88
+ if (config.runNow) {
89
+ run();
90
+ }
91
+
92
+ const handle = setInterval(run, config.intervalMs);
93
+ this._jobs.set(name, handle);
94
+
95
+ console.log(`✓ BackupScheduler: "${name}" scheduled every ${this._humanize(config.intervalMs)}`);
96
+ return name;
97
+ }
98
+
99
+ /**
100
+ * Stop a specific scheduled job.
101
+ * @param {string} name
102
+ */
103
+ stop(name) {
104
+ if (this._jobs.has(name)) {
105
+ clearInterval(this._jobs.get(name));
106
+ this._jobs.delete(name);
107
+ console.log(`✓ BackupScheduler: job "${name}" stopped`);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Stop all scheduled jobs.
113
+ */
114
+ stopAll() {
115
+ for (const name of this._jobs.keys()) {
116
+ this.stop(name);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Returns a list of currently active job names.
122
+ * @returns {string[]}
123
+ */
124
+ activeJobs() {
125
+ return [...this._jobs.keys()];
126
+ }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+ // Private helpers
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Execute the appropriate backup method and invoke callbacks.
134
+ * @private
135
+ */
136
+ async _execute(type, config) {
137
+ try {
138
+ let filePath;
139
+ const opts = { format: config.format, flush: config.flush };
140
+
141
+ if (type === 'full') {
142
+ filePath = await this.manager.full(opts);
143
+ } else if (type === 'partial') {
144
+ filePath = await this.manager.partial(config.tables, opts);
145
+ } else if (type === 'journal') {
146
+ filePath = await this.manager.journal(opts);
147
+ }
148
+
149
+ if (typeof config.onSuccess === 'function') {
150
+ config.onSuccess(filePath);
151
+ }
152
+ } catch (err) {
153
+ if (typeof config.onError === 'function') {
154
+ config.onError(err);
155
+ } else {
156
+ console.error(`BackupScheduler error (${type}):`, err.message);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Convert milliseconds to a human-readable string.
163
+ * @private
164
+ * @param {number} ms
165
+ * @returns {string}
166
+ */
167
+ _humanize(ms) {
168
+ if (ms < 60_000) return `${ms / 1000}s`;
169
+ if (ms < 3_600_000) return `${ms / 60_000}min`;
170
+ if (ms < 86_400_000) return `${ms / 3_600_000}h`;
171
+ return `${ms / 86_400_000}d`;
172
+ }
173
+ }
174
+
175
+ module.exports = BackupScheduler;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * BackupSocketClient
3
+ *
4
+ * Connects to a BackupSocketServer daemon and exposes a promise-based API
5
+ * to schedule, stop, and trigger backup jobs remotely.
6
+ *
7
+ * Usage:
8
+ * const client = new BackupSocketClient({ port: 9119 });
9
+ * await client.connect();
10
+ *
11
+ * await client.schedule('full', { intervalMs: 3600000, name: 'hourly' });
12
+ * await client.run('partial', ['users', 'orders'], { format: 'json' });
13
+ * const status = await client.status();
14
+ *
15
+ * client.on('jobDone', ({ name, filePath }) => console.log('Done:', filePath));
16
+ * client.on('jobError', ({ name, error }) => console.error('Error:', error));
17
+ *
18
+ * await client.disconnect();
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const net = require('net');
24
+ const events = require('events');
25
+
26
+ const DEFAULT_PORT = 9119;
27
+ const DEFAULT_HOST = '127.0.0.1';
28
+ const DEFAULT_TIMEOUT = 30_000; // ms to wait for a server reply
29
+
30
+ class BackupSocketClient extends events.EventEmitter {
31
+ /**
32
+ * @param {object} [options]
33
+ * @param {number} [options.port=9119]
34
+ * @param {string} [options.host='127.0.0.1']
35
+ * @param {number} [options.timeout=30000] Reply timeout in ms
36
+ */
37
+ constructor(options = {}) {
38
+ super();
39
+ this.port = options.port || DEFAULT_PORT;
40
+ this.host = options.host || DEFAULT_HOST;
41
+ this.timeout = options.timeout || DEFAULT_TIMEOUT;
42
+
43
+ this._socket = null;
44
+ this._buffer = '';
45
+ this._pending = []; // [{ resolve, reject, timer }]
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // Connection
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Establish a connection to the BackupSocketServer.
54
+ * @returns {Promise<void>}
55
+ */
56
+ connect() {
57
+ return new Promise((resolve, reject) => {
58
+ this._socket = net.createConnection({ port: this.port, host: this.host }, () => {
59
+ this._socket.setEncoding('utf8');
60
+ this.emit('connect');
61
+ resolve();
62
+ });
63
+
64
+ this._socket.on('data', (chunk) => this._onData(chunk));
65
+ this._socket.on('error', (err) => this._onError(err));
66
+ this._socket.on('close', () => this._onClose());
67
+
68
+ this._socket.once('error', reject);
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Close the connection to the server.
74
+ * @returns {Promise<void>}
75
+ */
76
+ disconnect() {
77
+ return new Promise((resolve) => {
78
+ if (!this._socket) { resolve(); return; }
79
+ this._socket.once('close', resolve);
80
+ this._socket.end();
81
+ });
82
+ }
83
+
84
+ /** Whether the client has an active socket. */
85
+ get connected() {
86
+ return this._socket !== null && !this._socket.destroyed;
87
+ }
88
+
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+ // Remote API
91
+ // ─────────────────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Ping the server.
95
+ * @returns {Promise<'pong'>}
96
+ */
97
+ ping() {
98
+ return this._send({ action: 'ping' });
99
+ }
100
+
101
+ /**
102
+ * Get server status (uptime, active jobs, connected clients).
103
+ * @returns {Promise<{ uptime: number, jobs: string[], clients: number }>}
104
+ */
105
+ status() {
106
+ return this._send({ action: 'status' });
107
+ }
108
+
109
+ /**
110
+ * List active job names.
111
+ * @returns {Promise<string[]>}
112
+ */
113
+ jobs() {
114
+ return this._send({ action: 'jobs' });
115
+ }
116
+
117
+ /**
118
+ * Schedule a recurring backup job on the server.
119
+ *
120
+ * @param {'full'|'partial'|'journal'} type
121
+ * @param {object} config
122
+ * @param {number} config.intervalMs
123
+ * @param {string} [config.name]
124
+ * @param {string[]} [config.tables] Required for 'partial'
125
+ * @param {string} [config.format]
126
+ * @param {boolean} [config.flush]
127
+ * @param {boolean} [config.runNow]
128
+ * @param {boolean} [config.encrypt]
129
+ * @param {string} [config.encryptionPassword]
130
+ * @param {number} [config.saltLength]
131
+ * @returns {Promise<string>} Job name
132
+ */
133
+ schedule(type, config) {
134
+ return this._send({ action: 'schedule', type, config });
135
+ }
136
+
137
+ /**
138
+ * Stop a scheduled job by name.
139
+ * @param {string} name
140
+ * @returns {Promise<boolean>}
141
+ */
142
+ stop(name) {
143
+ return this._send({ action: 'stop', name });
144
+ }
145
+
146
+ /**
147
+ * Stop all scheduled jobs on the server.
148
+ * @returns {Promise<boolean>}
149
+ */
150
+ stopAll() {
151
+ return this._send({ action: 'stopAll' });
152
+ }
153
+
154
+ /**
155
+ * Trigger an immediate (one-shot) backup.
156
+ *
157
+ * @param {'full'|'partial'|'journal'} type
158
+ * @param {string[]} [tables] Required for 'partial'
159
+ * @param {object} [options]
160
+ * @param {string} [options.format]
161
+ * @param {string} [options.filename]
162
+ * @param {boolean} [options.encrypt]
163
+ * @param {string} [options.encryptionPassword]
164
+ * @param {number} [options.saltLength]
165
+ * @returns {Promise<string>} Absolute file path of the created backup
166
+ */
167
+ run(type, tables, options = {}) {
168
+ // Allow calling run('full', options) without tables
169
+ if (tables && !Array.isArray(tables)) {
170
+ options = tables;
171
+ tables = undefined;
172
+ }
173
+ return this._send({ action: 'run', type, tables, options });
174
+ }
175
+
176
+ /**
177
+ * Restore a previously created backup file (SQL or encrypted .enc).
178
+ *
179
+ * @param {string} filePath Absolute path to the backup file on the server
180
+ * @param {object} [options]
181
+ * @param {string} [options.encryptionPassword] Required if the file is encrypted
182
+ * @returns {Promise<{ statements: number }>}
183
+ */
184
+ restore(filePath, options = {}) {
185
+ return this._send({ action: 'restore', filePath, options });
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // Internal socket plumbing
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Send a command and wait for the corresponding reply.
194
+ * Replies are matched in FIFO order (server always echoes in order).
195
+ * @private
196
+ */
197
+ _send(payload) {
198
+ return new Promise((resolve, reject) => {
199
+ if (!this.connected) {
200
+ return reject(new Error('BackupSocketClient: not connected'));
201
+ }
202
+
203
+ const timer = setTimeout(() => {
204
+ const idx = this._pending.findIndex((p) => p.reject === reject);
205
+ if (idx !== -1) this._pending.splice(idx, 1);
206
+ reject(new Error(`BackupSocketClient: timeout waiting for reply to "${payload.action}"`));
207
+ }, this.timeout);
208
+
209
+ this._pending.push({ resolve, reject, timer });
210
+ this._socket.write(JSON.stringify(payload) + '\n');
211
+ });
212
+ }
213
+
214
+ /** @private */
215
+ _onData(chunk) {
216
+ this._buffer += chunk;
217
+ const lines = this._buffer.split('\n');
218
+ this._buffer = lines.pop();
219
+
220
+ for (const line of lines) {
221
+ const trimmed = line.trim();
222
+ if (!trimmed) continue;
223
+
224
+ let msg;
225
+ try {
226
+ msg = JSON.parse(trimmed);
227
+ } catch (_) {
228
+ continue;
229
+ }
230
+
231
+ // Server push events (no matching request)
232
+ if (msg.event) {
233
+ this.emit(msg.event, msg);
234
+ this.emit('serverEvent', msg);
235
+ continue;
236
+ }
237
+
238
+ // Reply to a pending request (FIFO)
239
+ const pending = this._pending.shift();
240
+ if (!pending) continue;
241
+
242
+ clearTimeout(pending.timer);
243
+
244
+ if (msg.ok) {
245
+ pending.resolve(msg.result);
246
+ } else {
247
+ pending.reject(new Error(msg.error || 'Unknown server error'));
248
+ }
249
+ }
250
+ }
251
+
252
+ /** @private */
253
+ _onError(err) {
254
+ this.emit('error', err);
255
+ // Reject all pending requests
256
+ for (const { reject, timer } of this._pending) {
257
+ clearTimeout(timer);
258
+ reject(err);
259
+ }
260
+ this._pending = [];
261
+ }
262
+
263
+ /** @private */
264
+ _onClose() {
265
+ this._socket = null;
266
+ this.emit('disconnect');
267
+ for (const { reject, timer } of this._pending) {
268
+ clearTimeout(timer);
269
+ reject(new Error('BackupSocketClient: connection closed'));
270
+ }
271
+ this._pending = [];
272
+ }
273
+ }
274
+
275
+ module.exports = BackupSocketClient;