outlet-orm 5.5.3 → 6.5.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/README.md +4 -2
- package/package.json +1 -1
- package/src/Backup/BackupEncryption.js +153 -0
- package/src/Backup/BackupManager.js +422 -0
- package/src/Backup/BackupScheduler.js +175 -0
- package/src/Backup/BackupSocketClient.js +275 -0
- package/src/Backup/BackupSocketServer.js +347 -0
- package/src/Model.js +154 -2
- package/src/QueryBuilder.js +82 -0
- package/src/index.js +15 -1
- package/types/index.d.ts +266 -0
|
@@ -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;
|