outlet-orm 5.5.3 → 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,347 @@
1
+ /**
2
+ * BackupSocketServer
3
+ *
4
+ * A long-running TCP daemon that manages backup jobs for outlet-orm.
5
+ * Clients connect, send JSON commands, and receive JSON responses.
6
+ *
7
+ * Protocol: newline-delimited JSON (NDJSON) over TCP.
8
+ *
9
+ * ─── Client → Server commands ─────────────────────────────────────────
10
+ * { "action": "schedule", "type": "full|partial|journal",
11
+ * "config": { "intervalMs": 3600000, "tables": [...], "name": "...",
12
+ * "format": "sql|json", "flush": false,
13
+ * "encrypt": true, "encryptionPassword": "...", "saltLength": 6 } }
14
+ *
15
+ * { "action": "stop", "name": "job_name" }
16
+ * { "action": "stopAll" }
17
+ * { "action": "jobs" }
18
+ * { "action": "run", "type": "full|partial|journal",
19
+ * "tables": [...], "options": { "format": "sql", "filename": "..." } }
20
+ * { "action": "restore", "filePath": "/abs/path/to/backup.sql",
21
+ * "options": { "encryptionPassword": "..." } }
22
+ * { "action": "status" }
23
+ * { "action": "ping" }
24
+ *
25
+ * ─── Server → Client responses ────────────────────────────────────────
26
+ * { "ok": true, "result": <any> }
27
+ * { "ok": false, "error": "message" }
28
+ *
29
+ * ─── Server → All clients (push events) ──────────────────────────────
30
+ * { "event": "jobStart", "name": "...", "type": "..." }
31
+ * { "event": "jobDone", "name": "...", "type": "...", "filePath": "..." }
32
+ * { "event": "jobError", "name": "...", "type": "...", "error": "..." }
33
+ *
34
+ * Usage:
35
+ * const server = new BackupSocketServer(db, { port: 9119, backupPath: './database/backups' });
36
+ * await server.listen(); // starts the daemon
37
+ * // ...
38
+ * await server.close(); // graceful shutdown
39
+ */
40
+
41
+ 'use strict';
42
+
43
+ const net = require('net');
44
+ const events = require('events');
45
+ const BackupManager = require('./BackupManager');
46
+ const BackupScheduler = require('./BackupScheduler');
47
+
48
+ const DEFAULT_PORT = 9119;
49
+ const DEFAULT_HOST = '127.0.0.1';
50
+
51
+ class BackupSocketServer extends events.EventEmitter {
52
+ /**
53
+ * @param {import('../DatabaseConnection')} connection
54
+ * @param {object} [options]
55
+ * @param {number} [options.port=9119]
56
+ * @param {string} [options.host='127.0.0.1']
57
+ * @param {string} [options.backupPath] Forwarded to BackupManager
58
+ * @param {boolean} [options.encrypt] Default encryption for all jobs
59
+ * @param {string} [options.encryptionPassword] Default encryption password
60
+ * @param {number} [options.saltLength] Default grain de sable length
61
+ */
62
+ constructor(connection, options = {}) {
63
+ super();
64
+ this.connection = connection;
65
+ this.port = options.port || DEFAULT_PORT;
66
+ this.host = options.host || DEFAULT_HOST;
67
+
68
+ this._managerOptions = {
69
+ backupPath : options.backupPath,
70
+ encrypt : options.encrypt,
71
+ encryptionPassword : options.encryptionPassword,
72
+ saltLength : options.saltLength,
73
+ };
74
+
75
+ this.manager = new BackupManager(connection, this._managerOptions);
76
+ this.scheduler = new BackupScheduler(connection, this._managerOptions);
77
+
78
+ /** @type {Set<net.Socket>} */
79
+ this._clients = new Set();
80
+ this._server = null;
81
+ this._startTime = null;
82
+ }
83
+
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ // Lifecycle
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Start the TCP server and begin accepting connections.
90
+ * @returns {Promise<void>}
91
+ */
92
+ listen() {
93
+ return new Promise((resolve, reject) => {
94
+ this._server = net.createServer((socket) => this._handleClient(socket));
95
+
96
+ this._server.on('error', (err) => {
97
+ this.emit('error', err);
98
+ reject(err);
99
+ });
100
+
101
+ this._server.listen(this.port, this.host, () => {
102
+ this._startTime = Date.now();
103
+ const addr = `${this.host}:${this.port}`;
104
+ console.log(`✓ BackupSocketServer listening on ${addr}`);
105
+ this.emit('listening', { host: this.host, port: this.port });
106
+ resolve();
107
+ });
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Gracefully stop the server and all scheduled jobs.
113
+ * @returns {Promise<void>}
114
+ */
115
+ close() {
116
+ return new Promise((resolve) => {
117
+ this.scheduler.stopAll();
118
+
119
+ // Close all client connections
120
+ for (const sock of this._clients) {
121
+ try { sock.destroy(); } catch (_) { /* ignore */ }
122
+ }
123
+ this._clients.clear();
124
+
125
+ if (this._server) {
126
+ this._server.close(() => {
127
+ console.log('✓ BackupSocketServer closed');
128
+ this.emit('close');
129
+ resolve();
130
+ });
131
+ } else {
132
+ resolve();
133
+ }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Return server address info.
139
+ * @returns {{ host: string, port: number } | null}
140
+ */
141
+ address() {
142
+ if (!this._server) return null;
143
+ const addr = this._server.address();
144
+ return addr ? { host: addr.address, port: addr.port } : null;
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // Client handling
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ /** @private */
152
+ _handleClient(socket) {
153
+ this._clients.add(socket);
154
+ let buffer = '';
155
+
156
+ socket.setEncoding('utf8');
157
+
158
+ socket.on('data', (chunk) => {
159
+ buffer += chunk;
160
+ // Messages are newline-delimited
161
+ const lines = buffer.split('\n');
162
+ buffer = lines.pop(); // keep incomplete last line
163
+
164
+ for (const line of lines) {
165
+ const trimmed = line.trim();
166
+ if (!trimmed) continue;
167
+ this._processMessage(socket, trimmed);
168
+ }
169
+ });
170
+
171
+ socket.on('close', () => {
172
+ this._clients.delete(socket);
173
+ });
174
+
175
+ socket.on('error', (err) => {
176
+ this._clients.delete(socket);
177
+ this.emit('clientError', err);
178
+ });
179
+ }
180
+
181
+ /** @private */
182
+ async _processMessage(socket, raw) {
183
+ let msg;
184
+ try {
185
+ msg = JSON.parse(raw);
186
+ } catch (_) {
187
+ this._send(socket, { ok: false, error: 'Invalid JSON' });
188
+ return;
189
+ }
190
+
191
+ try {
192
+ const result = await this._dispatch(msg);
193
+ this._send(socket, { ok: true, result });
194
+ } catch (err) {
195
+ this._send(socket, { ok: false, error: err.message || String(err) });
196
+ }
197
+ }
198
+
199
+ /** @private */
200
+ async _dispatch(msg) {
201
+ switch (msg.action) {
202
+
203
+ case 'ping':
204
+ return 'pong';
205
+
206
+ case 'status':
207
+ return {
208
+ uptime : this._startTime ? Date.now() - this._startTime : 0,
209
+ jobs : this.scheduler.activeJobs(),
210
+ clients: this._clients.size,
211
+ };
212
+
213
+ case 'jobs':
214
+ return this.scheduler.activeJobs();
215
+
216
+ case 'schedule': {
217
+ const type = msg.type;
218
+ const config = Object.assign({}, msg.config || {});
219
+
220
+ // Merge per-job encryption options with server defaults
221
+ const jobManagerOpts = this._buildManagerOptions(config);
222
+
223
+ // Use a mutable ref so callbacks always carry the final job name
224
+ // (which may be auto-generated if config.name was not provided)
225
+ const nameRef = { value: config.name || type };
226
+
227
+ // Wrap success/error to emit events to all connected clients
228
+ const originalOnSuccess = config.onSuccess;
229
+ const originalOnError = config.onError;
230
+
231
+ config.onSuccess = (filePath) => {
232
+ this._broadcast({ event: 'jobDone', name: nameRef.value, type, filePath });
233
+ if (typeof originalOnSuccess === 'function') originalOnSuccess(filePath);
234
+ };
235
+ config.onError = (err) => {
236
+ this._broadcast({ event: 'jobError', name: nameRef.value, type, error: err.message });
237
+ if (typeof originalOnError === 'function') originalOnError(err);
238
+ };
239
+
240
+ // Create a dedicated scheduler with the right per-job manager options
241
+ const jobScheduler = this._buildScheduler(jobManagerOpts);
242
+ const name = jobScheduler.schedule(type, config);
243
+
244
+ // Update ref with actual name (covers auto-generated names)
245
+ nameRef.value = name;
246
+
247
+ // Persist the interval handle in the main scheduler so status/stop work
248
+ this.scheduler._jobs.set(name, jobScheduler._jobs.get(name));
249
+
250
+ this._broadcast({ event: 'jobStart', name, type });
251
+ return name;
252
+ }
253
+
254
+ case 'stop':
255
+ this.scheduler.stop(msg.name);
256
+ return true;
257
+
258
+ case 'stopAll':
259
+ this.scheduler.stopAll();
260
+ return true;
261
+
262
+ case 'run': {
263
+ const type = msg.type;
264
+ const options = msg.options || {};
265
+ const tables = msg.tables;
266
+ const runManagerOpts = this._buildManagerOptions(options);
267
+ const runManager = new BackupManager(this.connection, runManagerOpts);
268
+
269
+ let filePath;
270
+ if (type === 'full') {
271
+ filePath = await runManager.full(options);
272
+ } else if (type === 'partial') {
273
+ if (!Array.isArray(tables) || tables.length === 0) {
274
+ throw new Error("'tables' array is required for partial backup");
275
+ }
276
+ filePath = await runManager.partial(tables, options);
277
+ } else if (type === 'journal') {
278
+ filePath = await runManager.journal(options);
279
+ } else {
280
+ throw new Error(`Unknown backup type "${type}"`);
281
+ }
282
+ return filePath;
283
+ }
284
+
285
+ case 'restore': {
286
+ if (!msg.filePath) {
287
+ throw new Error("'filePath' is required for restore");
288
+ }
289
+ // Build a restore-capable manager (needs encryptionPassword if file is encrypted)
290
+ const restoreOpts = Object.assign({}, this._managerOptions, {
291
+ encrypt : false, // manager doesn't need to encrypt on restore
292
+ encryptionPassword: (msg.options && msg.options.encryptionPassword)
293
+ || this._managerOptions.encryptionPassword,
294
+ });
295
+ const restoreManager = new BackupManager(this.connection, restoreOpts);
296
+ return restoreManager.restore(msg.filePath, msg.options || {});
297
+ }
298
+
299
+ default:
300
+ throw new Error(`Unknown action "${msg.action}"`);
301
+ }
302
+ }
303
+
304
+ // ─────────────────────────────────────────────────────────────────────────────
305
+ // Helpers
306
+ // ─────────────────────────────────────────────────────────────────────────────
307
+
308
+ /** @private */
309
+ _send(socket, payload) {
310
+ if (socket.writable) {
311
+ socket.write(JSON.stringify(payload) + '\n');
312
+ }
313
+ }
314
+
315
+ /** @private */
316
+ _broadcast(payload) {
317
+ const line = JSON.stringify(payload) + '\n';
318
+ for (const sock of this._clients) {
319
+ if (sock.writable) sock.write(line);
320
+ }
321
+ this.emit('event', payload);
322
+ }
323
+
324
+ /**
325
+ * Build BackupManager options, merging server defaults with per-call overrides.
326
+ * @private
327
+ */
328
+ _buildManagerOptions(overrides = {}) {
329
+ return {
330
+ backupPath : overrides.backupPath || this._managerOptions.backupPath,
331
+ encrypt : overrides.encrypt != null ? overrides.encrypt : this._managerOptions.encrypt,
332
+ encryptionPassword: overrides.encryptionPassword || this._managerOptions.encryptionPassword,
333
+ saltLength : overrides.saltLength != null ? overrides.saltLength : this._managerOptions.saltLength,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Build a throw-away BackupScheduler with a given manager.
339
+ * @private
340
+ */
341
+ _buildScheduler(managerOptions) {
342
+ const s = new BackupScheduler(this.connection, managerOptions);
343
+ return s;
344
+ }
345
+ }
346
+
347
+ module.exports = BackupSocketServer;
package/src/index.js CHANGED
@@ -21,6 +21,13 @@ const MigrationManager = require('./Migrations/MigrationManager');
21
21
  const Seeder = require('./Seeders/Seeder');
22
22
  const SeederManager = require('./Seeders/SeederManager');
23
23
 
24
+ // Backup
25
+ const BackupManager = require('./Backup/BackupManager');
26
+ const BackupScheduler = require('./Backup/BackupScheduler');
27
+ const BackupEncryption = require('./Backup/BackupEncryption');
28
+ const BackupSocketServer = require('./Backup/BackupSocketServer');
29
+ const BackupSocketClient = require('./Backup/BackupSocketClient');
30
+
24
31
  module.exports = {
25
32
  // Core
26
33
  Model,
@@ -51,5 +58,12 @@ module.exports = {
51
58
 
52
59
  // Seeders
53
60
  Seeder,
54
- SeederManager
61
+ SeederManager,
62
+
63
+ // Backup
64
+ BackupManager,
65
+ BackupScheduler,
66
+ BackupEncryption,
67
+ BackupSocketServer,
68
+ BackupSocketClient
55
69
  };
package/types/index.d.ts CHANGED
@@ -671,4 +671,229 @@ declare module 'outlet-orm' {
671
671
  run(target?: string | null): Promise<void>;
672
672
  runSeeder(seederRef: string): Promise<void>;
673
673
  }
674
+
675
+ // ==================== Backup ====================
676
+
677
+ export type BackupFormat = 'sql' | 'json';
678
+ export type BackupType = 'full' | 'partial' | 'journal';
679
+ export type SaltLength = 4 | 5 | 6;
680
+
681
+ /** Encryption options for backup files */
682
+ export interface EncryptionOptions {
683
+ /** Enable AES-256-GCM encryption (default: false) */
684
+ encrypt?: boolean;
685
+ /** Password used for key derivation (required when encrypt=true) */
686
+ encryptionPassword?: string;
687
+ /**
688
+ * Grain de sable (salt) length in characters.
689
+ * Must be 4, 5, or 6. Default: 6.
690
+ */
691
+ saltLength?: SaltLength;
692
+ }
693
+
694
+ export interface BackupOptions extends EncryptionOptions {
695
+ /** Override the auto-generated filename */
696
+ filename?: string;
697
+ /** Output format – 'sql' (default) or 'json' */
698
+ format?: BackupFormat;
699
+ }
700
+
701
+ export interface JournalOptions extends EncryptionOptions {
702
+ /** Override the auto-generated filename */
703
+ filename?: string;
704
+ /** Clear the query log after writing the journal (default: false) */
705
+ flush?: boolean;
706
+ }
707
+
708
+ export interface RestoreOptions {
709
+ /**
710
+ * Password to decrypt an encrypted backup.
711
+ * Falls back to the BackupManager constructor password.
712
+ */
713
+ encryptionPassword?: string;
714
+ }
715
+
716
+ export interface RestoreResult {
717
+ /** Number of SQL statements executed */
718
+ statements: number;
719
+ }
720
+
721
+ export interface BackupManagerOptions extends EncryptionOptions {
722
+ /** Directory where backup files are written (default: './database/backups') */
723
+ backupPath?: string;
724
+ }
725
+
726
+ export interface ScheduleConfig extends EncryptionOptions {
727
+ /** Interval between backups in milliseconds (minimum: 1000) */
728
+ intervalMs: number;
729
+ /** Run once immediately when scheduled (default: false) */
730
+ runNow?: boolean;
731
+ /** Table names – required for 'partial' type */
732
+ tables?: string[];
733
+ /** Output format for full/partial backups */
734
+ format?: BackupFormat;
735
+ /** Auto-flush query log after each journal backup */
736
+ flush?: boolean;
737
+ /** Called with the file path after a successful backup */
738
+ onSuccess?: (filePath: string) => void;
739
+ /** Called with the error when a backup fails */
740
+ onError?: (error: Error) => void;
741
+ /** Optional unique job identifier (defaults to "<type>_<timestamp>") */
742
+ name?: string;
743
+ }
744
+
745
+ export class BackupManager {
746
+ constructor(connection: DatabaseConnection, options?: BackupManagerOptions);
747
+
748
+ /** Full backup – schema + data for every table. */
749
+ full(options?: BackupOptions): Promise<string>;
750
+ /** Partial backup – schema + data for the specified tables only. */
751
+ partial(tables: string[], options?: BackupOptions): Promise<string>;
752
+ /**
753
+ * Transaction-log backup – replayable DML statements from the query log.
754
+ * Requires DatabaseConnection.enableQueryLog() to be called beforehand.
755
+ */
756
+ journal(options?: JournalOptions): Promise<string>;
757
+ /** Restore a previously created SQL backup file (supports encrypted files). */
758
+ restore(filePath: string, options?: RestoreOptions): Promise<RestoreResult>;
759
+ }
760
+
761
+ export class BackupScheduler {
762
+ constructor(connection: DatabaseConnection, options?: BackupManagerOptions);
763
+
764
+ schedule(type: BackupType, config: ScheduleConfig): string;
765
+ stop(name: string): void;
766
+ stopAll(): void;
767
+ activeJobs(): string[];
768
+ }
769
+
770
+ // ==================== Backup Encryption ====================
771
+
772
+ export interface EncryptResult {
773
+ /** Full file content to write to disk */
774
+ encryptedContent: string;
775
+ /** The grain de sable (salt) that was generated */
776
+ salt: string;
777
+ }
778
+
779
+ export namespace BackupEncryption {
780
+ /**
781
+ * Encrypt a string payload with AES-256-GCM.
782
+ * @param plaintext Content to encrypt
783
+ * @param password Encryption password
784
+ * @param saltLength Grain de sable length (4–6, default 6)
785
+ */
786
+ function encrypt(plaintext: string, password: string, saltLength?: SaltLength): EncryptResult;
787
+
788
+ /**
789
+ * Decrypt a previously encrypted backup payload.
790
+ * @param encryptedContent Raw file content produced by encrypt()
791
+ * @param password The same password used during encryption
792
+ */
793
+ function decrypt(encryptedContent: string, password: string): string;
794
+
795
+ /** Return true if the content looks like an outlet-orm encrypted backup. */
796
+ function isEncrypted(content: string): boolean;
797
+
798
+ /**
799
+ * Generate a random alphanumeric salt (grain de sable).
800
+ * @param length 4, 5, or 6 characters
801
+ */
802
+ function generateSalt(length?: SaltLength): string;
803
+ }
804
+
805
+ // ==================== Backup Socket Server ====================
806
+
807
+ export interface ServerOptions extends BackupManagerOptions {
808
+ /** TCP port to listen on (default: 9119) */
809
+ port?: number;
810
+ /** Host / IP to bind (default: '127.0.0.1') */
811
+ host?: string;
812
+ }
813
+
814
+ export interface ServerStatus {
815
+ uptime: number;
816
+ jobs: string[];
817
+ clients: number;
818
+ }
819
+
820
+ export interface ServerAddress {
821
+ host: string;
822
+ port: number;
823
+ }
824
+
825
+ export class BackupSocketServer {
826
+ constructor(connection: DatabaseConnection, options?: ServerOptions);
827
+
828
+ /** Start the TCP daemon. */
829
+ listen(): Promise<void>;
830
+ /** Gracefully stop the daemon and all scheduled jobs. */
831
+ close(): Promise<void>;
832
+ /** Return the bound address (null before listen). */
833
+ address(): ServerAddress | null;
834
+
835
+ on(event: 'listening', listener: (addr: ServerAddress) => void): this;
836
+ on(event: 'close', listener: () => void): this;
837
+ on(event: 'error', listener: (err: Error) => void): this;
838
+ on(event: 'event', listener: (payload: Record<string, any>) => void): this;
839
+ on(event: string, listener: (...args: any[]) => void): this;
840
+ }
841
+
842
+ // ==================== Backup Socket Client ====================
843
+
844
+ export interface ClientOptions {
845
+ /** TCP port of the server (default: 9119) */
846
+ port?: number;
847
+ /** Host of the server (default: '127.0.0.1') */
848
+ host?: string;
849
+ /** Timeout in ms waiting for a server reply (default: 30000) */
850
+ timeout?: number;
851
+ }
852
+
853
+ export interface RunOptions extends EncryptionOptions {
854
+ format? : BackupFormat;
855
+ filename?: string;
856
+ flush? : boolean;
857
+ }
858
+
859
+ export class BackupSocketClient {
860
+ constructor(options?: ClientOptions);
861
+
862
+ readonly connected: boolean;
863
+
864
+ connect(): Promise<void>;
865
+ disconnect(): Promise<void>;
866
+
867
+ ping(): Promise<'pong'>;
868
+ status(): Promise<ServerStatus>;
869
+ jobs(): Promise<string[]>;
870
+
871
+ schedule(type: BackupType, config: ScheduleConfig): Promise<string>;
872
+ stop(name: string): Promise<boolean>;
873
+ stopAll(): Promise<boolean>;
874
+
875
+ /**
876
+ * Trigger a one-shot backup immediately.
877
+ * @param tables Required for 'partial' type
878
+ */
879
+ run(type: BackupType, options?: RunOptions): Promise<string>;
880
+ run(type: 'partial', tables: string[], options?: RunOptions): Promise<string>;
881
+
882
+ /**
883
+ * Restore a previously created backup file on the server.
884
+ * Supports plain SQL and encrypted .enc files.
885
+ * @param filePath Absolute path to the backup file (server-side)
886
+ * @param options Supply encryptionPassword when the file is encrypted
887
+ */
888
+ restore(filePath: string, options?: RestoreOptions): Promise<RestoreResult>;
889
+
890
+ on(event: 'connect', listener: () => void): this;
891
+ on(event: 'disconnect', listener: () => void): this;
892
+ on(event: 'error', listener: (err: Error) => void): this;
893
+ on(event: 'jobStart', listener: (payload: { name: string; type: string }) => void): this;
894
+ on(event: 'jobDone', listener: (payload: { name: string; type: string; filePath: string }) => void): this;
895
+ on(event: 'jobError', listener: (payload: { name: string; type: string; error: string }) => void): this;
896
+ on(event: 'serverEvent', listener: (payload: Record<string, any>) => void): this;
897
+ on(event: string, listener: (...args: any[]) => void): this;
898
+ }
674
899
  }