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
package/README.md
CHANGED
|
@@ -86,8 +86,9 @@ my-project/
|
|
|
86
86
|
├── database/
|
|
87
87
|
│ ├── config.js # Config migrations (outlet-init)
|
|
88
88
|
│ ├── migrations/ # Migration files
|
|
89
|
-
│
|
|
90
|
-
│
|
|
89
|
+
│ ├── seeds/ # Test/demo data
|
|
90
|
+
│ │ └── UserSeeder.js
|
|
91
|
+
│ └── backups/ # 🗄️ Backup files (full / partial / journal)
|
|
91
92
|
│
|
|
92
93
|
├── public/ # ✅ Public static files
|
|
93
94
|
│ ├── images/
|
|
@@ -195,6 +196,7 @@ async store(req, res) {
|
|
|
195
196
|
- **Ergonomic aliases**: `columns([...])`, `ordrer()` (typo alias for `orderBy`)
|
|
196
197
|
- **Raw queries**: `executeRawQuery()` and `execute()` (native driver results)
|
|
197
198
|
- **Complete Migrations** (create/alter/drop, index, foreign keys, batch tracking)
|
|
199
|
+
- **Database Backup** (v6.0.0): full/partial/journal backups, recurring scheduler, AES-256-GCM encryption, TCP daemon + remote client, automatic restore
|
|
198
200
|
- **Handy CLI tools**: `outlet-init`, `outlet-migrate`, `outlet-convert`
|
|
199
201
|
- **`.env` configuration** (loaded automatically)
|
|
200
202
|
- **Multi-database**: MySQL, PostgreSQL, and SQLite
|
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackupEncryption
|
|
3
|
+
*
|
|
4
|
+
* AES-256-GCM encryption/decryption for backup files.
|
|
5
|
+
* Uses only Node.js built-in `crypto` – zero external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Grain de sable (salt) concept:
|
|
8
|
+
* A random alphanumeric salt of 4–6 characters is generated for every
|
|
9
|
+
* encryption operation. The salt is stored in plain text inside the
|
|
10
|
+
* encrypted file header so that decryption can always reconstruct the
|
|
11
|
+
* exact key that was used without storing the salt separately.
|
|
12
|
+
*
|
|
13
|
+
* Encrypted file format (UTF-8 text):
|
|
14
|
+
* Line 1 : OUTLET_ENC_V1 – magic / version marker
|
|
15
|
+
* Line 2 : <salt> – 4–6 alphanumeric chars (grain de sable)
|
|
16
|
+
* Line 3 : <iv_hex> – 24-char hex (12-byte IV for GCM)
|
|
17
|
+
* Line 4 : <authTag_hex> – 32-char hex (16-byte GCM auth tag)
|
|
18
|
+
* Line 5 : <ciphertext_base64> – base64-encoded encrypted content
|
|
19
|
+
*
|
|
20
|
+
* Key derivation: scryptSync(password, salt, 32)
|
|
21
|
+
* N=16384, r=8, p=1 (scrypt defaults – safe for interactive use)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
29
|
+
const MAGIC = 'OUTLET_ENC_V1';
|
|
30
|
+
const IV_LENGTH = 12; // bytes – GCM recommended
|
|
31
|
+
const SALT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a random alphanumeric salt (grain de sable).
|
|
37
|
+
* @param {number} length 4 to 6 (inclusive)
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function generateSalt(length = 6) {
|
|
41
|
+
if (length < 4 || length > 6) {
|
|
42
|
+
throw new RangeError(`BackupEncryption: saltLength must be between 4 and 6 (got ${length})`);
|
|
43
|
+
}
|
|
44
|
+
const bytes = crypto.randomBytes(length);
|
|
45
|
+
return Array.from(bytes)
|
|
46
|
+
.map((b) => SALT_CHARS[b % SALT_CHARS.length])
|
|
47
|
+
.join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive a 256-bit key from a password and a salt using scrypt.
|
|
52
|
+
* @param {string} password
|
|
53
|
+
* @param {string} salt
|
|
54
|
+
* @returns {Buffer}
|
|
55
|
+
*/
|
|
56
|
+
function deriveKey(password, salt) {
|
|
57
|
+
return crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encrypt a string payload (backup content).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} plaintext The content to encrypt (SQL / JSON string)
|
|
66
|
+
* @param {string} password User-supplied encryption password
|
|
67
|
+
* @param {number} [saltLength=6] Grain de sable length (4–6 characters)
|
|
68
|
+
* @returns {{ encryptedContent: string, salt: string }}
|
|
69
|
+
* `encryptedContent` is the full file payload ready to write to disk.
|
|
70
|
+
* `salt` is exposed so callers can log / audit the grain de sable used.
|
|
71
|
+
*/
|
|
72
|
+
function encrypt(plaintext, password, saltLength = 6) {
|
|
73
|
+
if (!password || typeof password !== 'string') {
|
|
74
|
+
throw new TypeError('BackupEncryption.encrypt: password must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const salt = generateSalt(saltLength);
|
|
78
|
+
const key = deriveKey(password, salt);
|
|
79
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
80
|
+
|
|
81
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
82
|
+
const encrypted = Buffer.concat([
|
|
83
|
+
cipher.update(plaintext, 'utf8'),
|
|
84
|
+
cipher.final()
|
|
85
|
+
]);
|
|
86
|
+
const authTag = cipher.getAuthTag();
|
|
87
|
+
|
|
88
|
+
const encryptedContent = [
|
|
89
|
+
MAGIC,
|
|
90
|
+
salt,
|
|
91
|
+
iv.toString('hex'),
|
|
92
|
+
authTag.toString('hex'),
|
|
93
|
+
encrypted.toString('base64')
|
|
94
|
+
].join('\n');
|
|
95
|
+
|
|
96
|
+
return { encryptedContent, salt };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decrypt a previously encrypted backup payload.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} encryptedContent The raw file content (as written by encrypt())
|
|
103
|
+
* @param {string} password The same password used during encryption
|
|
104
|
+
* @returns {string} The original plaintext
|
|
105
|
+
* @throws If the format is invalid, the password is wrong, or the auth tag fails
|
|
106
|
+
*/
|
|
107
|
+
function decrypt(encryptedContent, password) {
|
|
108
|
+
if (!password || typeof password !== 'string') {
|
|
109
|
+
throw new TypeError('BackupEncryption.decrypt: password must be a non-empty string');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lines = encryptedContent.split('\n');
|
|
113
|
+
|
|
114
|
+
if (lines.length < 5 || lines[0] !== MAGIC) {
|
|
115
|
+
throw new Error('BackupEncryption.decrypt: invalid or unrecognised encrypted backup format');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const [, salt, ivHex, tagHex, ciphertextB64] = lines;
|
|
119
|
+
|
|
120
|
+
// Validate salt length (defensive)
|
|
121
|
+
if (salt.length < 4 || salt.length > 6) {
|
|
122
|
+
throw new Error(`BackupEncryption.decrypt: unexpected salt length (${salt.length})`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const key = deriveKey(password, salt);
|
|
126
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
127
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
128
|
+
const ciphertext = Buffer.from(ciphertextB64, 'base64');
|
|
129
|
+
|
|
130
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
131
|
+
decipher.setAuthTag(authTag);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const decrypted = Buffer.concat([
|
|
135
|
+
decipher.update(ciphertext),
|
|
136
|
+
decipher.final()
|
|
137
|
+
]);
|
|
138
|
+
return decrypted.toString('utf8');
|
|
139
|
+
} catch (_) {
|
|
140
|
+
throw new Error('BackupEncryption.decrypt: authentication failed – wrong password or corrupted data');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return true if the content looks like an outlet-orm encrypted backup.
|
|
146
|
+
* @param {string} content
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
function isEncrypted(content) {
|
|
150
|
+
return typeof content === 'string' && content.startsWith(MAGIC + '\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { encrypt, decrypt, isEncrypted, generateSalt };
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackupManager
|
|
3
|
+
*
|
|
4
|
+
* Provides programmatic backup capabilities for outlet-orm:
|
|
5
|
+
* - full : complete schema + data dump
|
|
6
|
+
* - partial : selected tables only
|
|
7
|
+
* - journal : transaction log replay (INSERT / UPDATE / DELETE captured via query log)
|
|
8
|
+
*
|
|
9
|
+
* Supports MySQL, PostgreSQL, and SQLite drivers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const fsPromises = require('fs').promises;
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const DatabaseConnection = require('../DatabaseConnection');
|
|
18
|
+
const { encrypt, decrypt, isEncrypted } = require('./BackupEncryption');
|
|
19
|
+
|
|
20
|
+
// DML keywords captured for transaction-log backups
|
|
21
|
+
const DML_REGEX = /^\s*(INSERT|UPDATE|DELETE|REPLACE)\s+/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Escape a SQL identifier (table / column name).
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @param {string} driver 'mysql' | 'postgres' | 'sqlite'
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function quoteIdentifier(name, driver) {
|
|
30
|
+
if (driver === 'mysql') return `\`${name.replace(/`/g, '``')}\``;
|
|
31
|
+
return `"${name.replace(/"/g, '""')}"`; // postgres / sqlite
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Quote a scalar value for use in a SQL dump statement.
|
|
36
|
+
* @param {*} val
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function quoteValue(val) {
|
|
40
|
+
if (val === null || val === undefined) return 'NULL';
|
|
41
|
+
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
42
|
+
// Escape single quotes
|
|
43
|
+
return `'${String(val).replace(/'/g, "''")}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate a human-readable timestamp suitable for filenames.
|
|
48
|
+
* @returns {string} e.g. "20260226_143022"
|
|
49
|
+
*/
|
|
50
|
+
function timestamp() {
|
|
51
|
+
const d = new Date();
|
|
52
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class BackupManager {
|
|
57
|
+
/**
|
|
58
|
+
* @param {DatabaseConnection} connection
|
|
59
|
+
* @param {object} [options]
|
|
60
|
+
* @param {string} [options.backupPath='./database/backups'] Directory for backup files
|
|
61
|
+
* @param {boolean} [options.encrypt=false] Encrypt backup files with AES-256-GCM
|
|
62
|
+
* @param {string} [options.encryptionPassword] Password used for encryption / decryption
|
|
63
|
+
* @param {number} [options.saltLength=6] Grain de sable length – 4, 5 or 6 characters
|
|
64
|
+
*/
|
|
65
|
+
constructor(connection, options = {}) {
|
|
66
|
+
if (!(connection instanceof DatabaseConnection)) {
|
|
67
|
+
throw new TypeError('BackupManager requires a DatabaseConnection instance');
|
|
68
|
+
}
|
|
69
|
+
this.connection = connection;
|
|
70
|
+
this.backupPath = path.resolve(
|
|
71
|
+
process.cwd(),
|
|
72
|
+
options.backupPath || './database/backups'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Encryption settings
|
|
76
|
+
this._encryptEnabled = Boolean(options.encrypt);
|
|
77
|
+
this._encryptPassword = options.encryptionPassword || null;
|
|
78
|
+
this._saltLength = options.saltLength != null ? options.saltLength : 6;
|
|
79
|
+
|
|
80
|
+
if (this._encryptEnabled && !this._encryptPassword) {
|
|
81
|
+
throw new Error('BackupManager: encryptionPassword is required when encrypt=true');
|
|
82
|
+
}
|
|
83
|
+
if (this._saltLength < 4 || this._saltLength > 6) {
|
|
84
|
+
throw new RangeError(`BackupManager: saltLength must be between 4 and 6 (got ${this._saltLength})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._ensureBackupDir();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Public API
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Full backup – dumps schema (CREATE TABLE) + data (INSERT) for every user table.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {string} [options.filename] Override the generated filename
|
|
99
|
+
* @param {string} [options.format='sql'] 'sql' | 'json'
|
|
100
|
+
* @returns {Promise<string>} Absolute path of the written backup file
|
|
101
|
+
*/
|
|
102
|
+
async full(options = {}) {
|
|
103
|
+
const tables = await this._listTables();
|
|
104
|
+
return this._dumpTables(tables, 'full', options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Partial backup – dumps schema + data for the specified tables only.
|
|
109
|
+
*
|
|
110
|
+
* @param {string[]} tables Table names to include
|
|
111
|
+
* @param {object} [options]
|
|
112
|
+
* @param {string} [options.filename]
|
|
113
|
+
* @param {string} [options.format='sql'] 'sql' | 'json'
|
|
114
|
+
* @returns {Promise<string>} Absolute path of the written backup file
|
|
115
|
+
*/
|
|
116
|
+
async partial(tables, options = {}) {
|
|
117
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
118
|
+
throw new Error('BackupManager.partial() requires a non-empty array of table names');
|
|
119
|
+
}
|
|
120
|
+
return this._dumpTables(tables, 'partial', options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Transaction-log backup – captures DML statements (INSERT / UPDATE / DELETE)
|
|
125
|
+
* from the DatabaseConnection query log and writes them as a replayable SQL script.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ Requires query logging to be enabled BEFORE the operations you want to record:
|
|
128
|
+
* DatabaseConnection.enableQueryLog()
|
|
129
|
+
*
|
|
130
|
+
* @param {object} [options]
|
|
131
|
+
* @param {string} [options.filename]
|
|
132
|
+
* @param {boolean} [options.flush=false] Clear the query log after writing the journal
|
|
133
|
+
* @returns {Promise<string>} Absolute path of the written journal file
|
|
134
|
+
*/
|
|
135
|
+
async journal(options = {}) {
|
|
136
|
+
const log = DatabaseConnection.getQueryLog();
|
|
137
|
+
// Filter to DML statements only
|
|
138
|
+
const dmlEntries = log.filter((entry) => DML_REGEX.test(entry.sql));
|
|
139
|
+
|
|
140
|
+
const driver = this.connection.driver;
|
|
141
|
+
const filename = options.filename || `journal_${timestamp()}.sql`;
|
|
142
|
+
const filePath = path.join(this.backupPath, filename);
|
|
143
|
+
|
|
144
|
+
const header = this._sqlHeader('transaction-log journal');
|
|
145
|
+
const lines = [header];
|
|
146
|
+
|
|
147
|
+
for (const entry of dmlEntries) {
|
|
148
|
+
const ts = entry.timestamp ? entry.timestamp.toISOString() : '';
|
|
149
|
+
lines.push(`-- ${ts} (${entry.duration}ms)`);
|
|
150
|
+
// Re-inject bound parameters into the SQL for a replayable statement
|
|
151
|
+
const replayable = this._injectParams(entry.sql, entry.params, driver);
|
|
152
|
+
lines.push(replayable + ';');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push(`-- end of journal (${dmlEntries.length} statement(s))`);
|
|
156
|
+
|
|
157
|
+
// Apply encryption-aware filename
|
|
158
|
+
const resolvedPath = (this._encryptEnabled && !filePath.endsWith('.enc'))
|
|
159
|
+
? filePath + '.enc'
|
|
160
|
+
: filePath;
|
|
161
|
+
|
|
162
|
+
await this._writeContent(resolvedPath, lines.join('\n'));
|
|
163
|
+
|
|
164
|
+
if (options.flush) {
|
|
165
|
+
DatabaseConnection.flushQueryLog();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(`✓ Transaction-log backup written: ${resolvedPath} (${dmlEntries.length} statement(s))`);
|
|
169
|
+
return resolvedPath;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Restore a previously created SQL backup file.
|
|
174
|
+
* The file is executed statement-by-statement inside a transaction.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} filePath Absolute or relative path to the .sql backup file
|
|
177
|
+
* @returns {Promise<{ statements: number }>}
|
|
178
|
+
*/
|
|
179
|
+
async restore(filePath, options = {}) {
|
|
180
|
+
const absolute = path.resolve(process.cwd(), filePath);
|
|
181
|
+
let content = await fsPromises.readFile(absolute, 'utf8');
|
|
182
|
+
|
|
183
|
+
// Decrypt if necessary
|
|
184
|
+
if (isEncrypted(content)) {
|
|
185
|
+
const password = options.encryptionPassword || this._encryptPassword;
|
|
186
|
+
if (!password) {
|
|
187
|
+
throw new Error('BackupManager.restore: file is encrypted – provide encryptionPassword');
|
|
188
|
+
}
|
|
189
|
+
content = decrypt(content, password);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Split on semicolon, ignore comment-only or empty chunks
|
|
193
|
+
const statements = content
|
|
194
|
+
.split(';')
|
|
195
|
+
.map((s) => s.trim())
|
|
196
|
+
.filter((s) => s && !s.startsWith('--'));
|
|
197
|
+
|
|
198
|
+
let count = 0;
|
|
199
|
+
await this.connection.transaction(async () => {
|
|
200
|
+
for (const sql of statements) {
|
|
201
|
+
await this.connection.execute(sql);
|
|
202
|
+
count++;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log(`✓ Restore complete: ${count} statement(s) executed from ${absolute}`);
|
|
207
|
+
return { statements: count };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Private helpers
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Dump a list of tables in the chosen format.
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
async _dumpTables(tables, label, options) {
|
|
219
|
+
const format = (options.format || 'sql').toLowerCase();
|
|
220
|
+
const ext = format === 'json' ? 'json' : 'sql';
|
|
221
|
+
const baseExt = this._encryptEnabled ? `${ext}.enc` : ext;
|
|
222
|
+
const filename = options.filename
|
|
223
|
+
? (this._encryptEnabled && !options.filename.endsWith('.enc') ? options.filename + '.enc' : options.filename)
|
|
224
|
+
: `${label}_${timestamp()}.${baseExt}`;
|
|
225
|
+
const filePath = path.join(this.backupPath, filename);
|
|
226
|
+
|
|
227
|
+
if (format === 'json') {
|
|
228
|
+
await this._dumpJSON(tables, filePath);
|
|
229
|
+
} else {
|
|
230
|
+
await this._dumpSQL(tables, filePath, label);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log(`✓ ${label.charAt(0).toUpperCase() + label.slice(1)} backup written: ${filePath}`);
|
|
234
|
+
return filePath;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Write a SQL dump (CREATE TABLE + INSERT INTO).
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
async _dumpSQL(tables, filePath, label) {
|
|
242
|
+
const driver = this.connection.driver;
|
|
243
|
+
const lines = [this._sqlHeader(label)];
|
|
244
|
+
|
|
245
|
+
for (const table of tables) {
|
|
246
|
+
lines.push(`\n-- Table: ${table}`);
|
|
247
|
+
lines.push(`-- -----------------------------------------------------------`);
|
|
248
|
+
|
|
249
|
+
// Schema
|
|
250
|
+
const createSql = await this._getCreateTable(table);
|
|
251
|
+
if (createSql) {
|
|
252
|
+
lines.push(createSql + ';');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Data
|
|
256
|
+
const rows = await this.connection.executeRawQuery(`SELECT * FROM ${quoteIdentifier(table, driver)}`);
|
|
257
|
+
if (rows.length > 0) {
|
|
258
|
+
const columns = Object.keys(rows[0]).map((c) => quoteIdentifier(c, driver)).join(', ');
|
|
259
|
+
for (const row of rows) {
|
|
260
|
+
const values = Object.values(row).map(quoteValue).join(', ');
|
|
261
|
+
lines.push(`INSERT INTO ${quoteIdentifier(table, driver)} (${columns}) VALUES (${values});`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
lines.push('\n-- End of backup');
|
|
267
|
+
await this._writeContent(filePath, lines.join('\n'));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Write a JSON dump { table: rows[] }.
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
async _dumpJSON(tables, filePath) {
|
|
275
|
+
const driver = this.connection.driver;
|
|
276
|
+
const dump = { generatedAt: new Date().toISOString(), tables: {} };
|
|
277
|
+
|
|
278
|
+
for (const table of tables) {
|
|
279
|
+
const rows = await this.connection.executeRawQuery(`SELECT * FROM ${quoteIdentifier(table, driver)}`);
|
|
280
|
+
dump.tables[table] = rows;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await this._writeContent(filePath, JSON.stringify(dump, null, 2));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* List all user tables from the connected database.
|
|
288
|
+
* @private
|
|
289
|
+
* @returns {Promise<string[]>}
|
|
290
|
+
*/
|
|
291
|
+
async _listTables() {
|
|
292
|
+
const driver = this.connection.driver;
|
|
293
|
+
let rows;
|
|
294
|
+
|
|
295
|
+
switch (driver) {
|
|
296
|
+
case 'mysql':
|
|
297
|
+
rows = await this.connection.executeRawQuery(
|
|
298
|
+
'SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = \'BASE TABLE\' ORDER BY table_name'
|
|
299
|
+
);
|
|
300
|
+
return rows.map((r) => r.name || r.TABLE_NAME);
|
|
301
|
+
|
|
302
|
+
case 'postgres':
|
|
303
|
+
case 'postgresql':
|
|
304
|
+
rows = await this.connection.executeRawQuery(
|
|
305
|
+
"SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
|
306
|
+
);
|
|
307
|
+
return rows.map((r) => r.name);
|
|
308
|
+
|
|
309
|
+
case 'sqlite':
|
|
310
|
+
rows = await this.connection.executeRawQuery(
|
|
311
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
312
|
+
);
|
|
313
|
+
return rows.map((r) => r.name);
|
|
314
|
+
|
|
315
|
+
default:
|
|
316
|
+
throw new Error(`BackupManager: unsupported driver "${driver}"`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Retrieve the CREATE TABLE statement for a given table.
|
|
322
|
+
* Returns null if not available for the current driver.
|
|
323
|
+
* @private
|
|
324
|
+
* @returns {Promise<string|null>}
|
|
325
|
+
*/
|
|
326
|
+
async _getCreateTable(table) {
|
|
327
|
+
const driver = this.connection.driver;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
switch (driver) {
|
|
331
|
+
case 'mysql': {
|
|
332
|
+
const rows = await this.connection.executeRawQuery(`SHOW CREATE TABLE ${quoteIdentifier(table, driver)}`);
|
|
333
|
+
return rows[0]?.['Create Table'] || null;
|
|
334
|
+
}
|
|
335
|
+
case 'sqlite': {
|
|
336
|
+
const rows = await this.connection.executeRawQuery(
|
|
337
|
+
'SELECT sql FROM sqlite_master WHERE type=\'table\' AND name=?',
|
|
338
|
+
[table]
|
|
339
|
+
);
|
|
340
|
+
return rows[0]?.sql || null;
|
|
341
|
+
}
|
|
342
|
+
case 'postgres':
|
|
343
|
+
case 'postgresql':
|
|
344
|
+
// PostgreSQL does not expose a single-row SHOW CREATE TABLE.
|
|
345
|
+
// Returning null; the INSERT statements are still written.
|
|
346
|
+
return null;
|
|
347
|
+
default:
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
} catch (_) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Rebuild a replayable SQL statement by injecting bound parameters.
|
|
357
|
+
* This is intentionally simple – values are quoted but not re-parameterized,
|
|
358
|
+
* so the result is a human-readable / replayable script rather than a prepared
|
|
359
|
+
* statement.
|
|
360
|
+
* @private
|
|
361
|
+
*/
|
|
362
|
+
_injectParams(sql, params, driver) {
|
|
363
|
+
if (!params || params.length === 0) return sql;
|
|
364
|
+
|
|
365
|
+
// Replace ? placeholders (MySQL/SQLite) or $1..$N placeholders (PostgreSQL)
|
|
366
|
+
if (driver === 'postgres' || driver === 'postgresql') {
|
|
367
|
+
let out = sql;
|
|
368
|
+
for (let i = 0; i < params.length; i++) {
|
|
369
|
+
out = out.replace(`$${i + 1}`, quoteValue(params[i]));
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// MySQL / SQLite – replace each ? in order
|
|
375
|
+
let idx = 0;
|
|
376
|
+
return sql.replace(/\?/g, () => quoteValue(params[idx++]));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build a standard SQL header comment block.
|
|
381
|
+
* @private
|
|
382
|
+
*/
|
|
383
|
+
_sqlHeader(type) {
|
|
384
|
+
return [
|
|
385
|
+
`-- outlet-orm backup`,
|
|
386
|
+
`-- type : ${type}`,
|
|
387
|
+
`-- driver : ${this.connection.driver}`,
|
|
388
|
+
`-- database : ${this.connection.config?.database || '(unknown)'}`,
|
|
389
|
+
`-- generated : ${new Date().toISOString()}`,
|
|
390
|
+
`-- ---------------------------------------------------------------`,
|
|
391
|
+
].join('\n');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Write content to disk, encrypting first when encryption is enabled.
|
|
396
|
+
* @private
|
|
397
|
+
* @param {string} filePath
|
|
398
|
+
* @param {string} content
|
|
399
|
+
*/
|
|
400
|
+
async _writeContent(filePath, content) {
|
|
401
|
+
if (this._encryptEnabled) {
|
|
402
|
+
const { encryptedContent, salt } = encrypt(content, this._encryptPassword, this._saltLength);
|
|
403
|
+
await fsPromises.writeFile(filePath, encryptedContent, 'utf8');
|
|
404
|
+
// Log the grain de sable (salt) for audit purposes without exposing the password
|
|
405
|
+
console.log(` 🔒 Encrypted (grain de sable: "${salt}", AES-256-GCM, scrypt key derivation)`);
|
|
406
|
+
} else {
|
|
407
|
+
await fsPromises.writeFile(filePath, content, 'utf8');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Ensure the backup directory exists.
|
|
413
|
+
* @private
|
|
414
|
+
*/
|
|
415
|
+
_ensureBackupDir() {
|
|
416
|
+
if (!fs.existsSync(this.backupPath)) {
|
|
417
|
+
fs.mkdirSync(this.backupPath, { recursive: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = BackupManager;
|