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 CHANGED
@@ -86,8 +86,9 @@ my-project/
86
86
  ├── database/
87
87
  │ ├── config.js # Config migrations (outlet-init)
88
88
  │ ├── migrations/ # Migration files
89
- └── seeds/ # Test/demo data
90
- └── UserSeeder.js
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outlet-orm",
3
- "version": "5.5.3",
3
+ "version": "6.5.0",
4
4
  "description": "A Laravel Eloquent-inspired ORM for Node.js with support for MySQL, PostgreSQL, and SQLite",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -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;