json-database-st 1.0.6 → 1.0.8
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/JSONDatabase.js +492 -484
- package/LICENSE +1 -1
- package/package.json +1 -1
package/JSONDatabase.js
CHANGED
|
@@ -1,485 +1,493 @@
|
|
|
1
|
-
// File: JSONDatabase.js
|
|
2
|
-
// Final, Complete, and Secure Version
|
|
3
|
-
|
|
4
|
-
const fs = require('fs').promises;
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const crypto = require('crypto');
|
|
7
|
-
const _ = require('lodash');
|
|
8
|
-
const EventEmitter = require('events');
|
|
9
|
-
|
|
10
|
-
// --- Custom Error Classes for Better Error Handling ---
|
|
11
|
-
|
|
12
|
-
/** Base error for all database-specific issues. */
|
|
13
|
-
class DBError extends Error {
|
|
14
|
-
constructor(message) {
|
|
15
|
-
super(message);
|
|
16
|
-
this.name = this.constructor.name;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
/** Error during database file initialization or parsing. */
|
|
20
|
-
class DBInitializationError extends DBError {}
|
|
21
|
-
/** Error within a user-provided transaction function. */
|
|
22
|
-
class TransactionError extends DBError {}
|
|
23
|
-
/** Error when data fails schema validation. */
|
|
24
|
-
class ValidationError extends DBError {
|
|
25
|
-
constructor(message, validationIssues) {
|
|
26
|
-
super(message);
|
|
27
|
-
this.issues = validationIssues; // e.g., from Zod/Joi
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/** Error related to index integrity (e.g., unique constraint violation). */
|
|
31
|
-
class IndexViolationError extends DBError {}
|
|
32
|
-
/** Error for security-related issues like path traversal or bad keys. */
|
|
33
|
-
class SecurityError extends DBError {}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// --- Type Definitions for Clarity ---
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {object} BatchOperationSet
|
|
40
|
-
* @property {'set'} type
|
|
41
|
-
* @property {string | string[]} path
|
|
42
|
-
* @property {any} value
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* @typedef {object} BatchOperationDelete
|
|
47
|
-
* @property {'delete'} type
|
|
48
|
-
* @property {string | string[]} path
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @typedef {object} BatchOperationPush
|
|
53
|
-
* @property {'push'} type
|
|
54
|
-
* @property {string | string[]} path
|
|
55
|
-
* @property {any[]} values - Items to push uniquely using deep comparison.
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @typedef {object} BatchOperationPull
|
|
60
|
-
* @property {'pull'} type
|
|
61
|
-
* @property {string | string[]} path
|
|
62
|
-
* @property {any[]} values - Items to remove using deep comparison.
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* @typedef {BatchOperationSet | BatchOperationDelete | BatchOperationPush | BatchOperationPull} BatchOperation
|
|
67
|
-
*/
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* @typedef {object} IndexDefinition
|
|
71
|
-
* @property {string} name - The unique name for the index.
|
|
72
|
-
* @property {string | string[]} path - The lodash path to the collection object (e.g., 'users').
|
|
73
|
-
* @property {string} field - The property field within each collection item to index (e.g., 'email').
|
|
74
|
-
* @property {boolean} [unique=false] - If true, enforces that the indexed field must be unique across the collection.
|
|
75
|
-
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// --- Cryptography Constants ---
|
|
79
|
-
const ALGORITHM = 'aes-256-gcm';
|
|
80
|
-
const IV_LENGTH = 16;
|
|
81
|
-
const AUTH_TAG_LENGTH = 16;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A robust, secure, promise-based JSON file database with atomic operations, indexing, schema validation, and events.
|
|
86
|
-
* Includes encryption-at-rest and path traversal protection.
|
|
87
|
-
*
|
|
88
|
-
* @class JSONDatabase
|
|
89
|
-
* @extends {EventEmitter}
|
|
90
|
-
*/
|
|
91
|
-
class JSONDatabase extends EventEmitter {
|
|
92
|
-
/**
|
|
93
|
-
* Creates a database instance.
|
|
94
|
-
*
|
|
95
|
-
* @param {string} filename - Database file path.
|
|
96
|
-
* @param {object} [options] - Configuration options.
|
|
97
|
-
* @param {string} [options.encryptionKey=null] - A 32-byte (64-character hex) secret key for encryption. If provided, enables encryption-at-rest. **MANAGE THIS KEY SECURELY.**
|
|
98
|
-
* @param {boolean} [options.prettyPrint=false] - Pretty-print JSON output (only if not encrypted).
|
|
99
|
-
* @param {boolean} [options.writeOnChange=true] - Only write to disk if data has changed.
|
|
100
|
-
* @param {object} [options.schema=null] - A validation schema (e.g., from Zod) with a `safeParse` method.
|
|
101
|
-
* @param {IndexDefinition[]} [options.indices=[]] - An array of index definitions for fast lookups.
|
|
102
|
-
* @throws {SecurityError} If the filename is invalid or attempts path traversal.
|
|
103
|
-
* @throws {SecurityError} If an encryption key is provided but is not the correct length.
|
|
104
|
-
*/
|
|
105
|
-
constructor(filename, options = {}) {
|
|
106
|
-
super();
|
|
107
|
-
|
|
108
|
-
// --- Security Check: Path Traversal ---
|
|
109
|
-
const resolvedPath = path.resolve(filename);
|
|
110
|
-
const workingDir = process.cwd();
|
|
111
|
-
if (!resolvedPath.startsWith(workingDir)) {
|
|
112
|
-
throw new SecurityError(`Path traversal detected. Database path must be within the project directory: ${workingDir}`);
|
|
113
|
-
}
|
|
114
|
-
this.filename = /\.json$/.test(resolvedPath) ? resolvedPath : `${resolvedPath}.json`;
|
|
115
|
-
|
|
116
|
-
// --- Security Check: Encryption Key ---
|
|
117
|
-
if (options.encryptionKey && (!options.encryptionKey || Buffer.from(options.encryptionKey, 'hex').length !== 32)) {
|
|
118
|
-
throw new SecurityError('Encryption key must be a 32-byte (64-character hex) string.');
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
this.config = {
|
|
122
|
-
prettyPrint: options.prettyPrint === true,
|
|
123
|
-
writeOnChange: options.writeOnChange !== false,
|
|
124
|
-
schema: options.schema || null,
|
|
125
|
-
indices: options.indices || [],
|
|
126
|
-
encryptionKey: options.encryptionKey ? Buffer.from(options.encryptionKey, 'hex') : null,
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
this.cache = null;
|
|
130
|
-
this.writeLock = Promise.resolve();
|
|
131
|
-
this.stats = { reads: 0, writes: 0, cacheHits: 0 };
|
|
132
|
-
this._indices = new Map();
|
|
133
|
-
|
|
134
|
-
// Asynchronously initialize. Operations will queue behind this promise.
|
|
135
|
-
this._initPromise = this._initialize();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// --- Encryption & Decryption ---
|
|
139
|
-
_encrypt(data) {
|
|
140
|
-
const iv = crypto.randomBytes(IV_LENGTH);
|
|
141
|
-
const cipher = crypto.createCipheriv(ALGORITHM, this.config.encryptionKey, iv);
|
|
142
|
-
const jsonString = JSON.stringify(data);
|
|
143
|
-
const encrypted = Buffer.concat([cipher.update(jsonString, 'utf8'), cipher.final()]);
|
|
144
|
-
const authTag = cipher.getAuthTag();
|
|
145
|
-
return JSON.stringify({
|
|
146
|
-
iv: iv.toString('hex'),
|
|
147
|
-
tag: authTag.toString('hex'),
|
|
148
|
-
content: encrypted.toString('hex'),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
_decrypt(encryptedPayload) {
|
|
153
|
-
try {
|
|
154
|
-
const payload = JSON.parse(encryptedPayload);
|
|
155
|
-
const iv = Buffer.from(payload.iv, 'hex');
|
|
156
|
-
const authTag = Buffer.from(payload.tag, 'hex');
|
|
157
|
-
const encryptedContent = Buffer.from(payload.content, 'hex');
|
|
158
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, this.config.encryptionKey, iv);
|
|
159
|
-
decipher.setAuthTag(authTag);
|
|
160
|
-
const decrypted = decipher.update(encryptedContent, 'hex', 'utf8') + decipher.final('utf8');
|
|
161
|
-
return JSON.parse(decrypted);
|
|
162
|
-
} catch (e) {
|
|
163
|
-
throw new SecurityError('Decryption failed. The file may be corrupted, tampered with, or the encryption key is incorrect.');
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// --- Private Core Methods ---
|
|
168
|
-
|
|
169
|
-
/** @private Kicks off the initialization process. */
|
|
170
|
-
async _initialize() {
|
|
171
|
-
try {
|
|
172
|
-
await this._refreshCache();
|
|
173
|
-
this._rebuildAllIndices();
|
|
174
|
-
} catch (err) {
|
|
175
|
-
const initError = new DBInitializationError(`Failed to initialize database: ${err.message}`);
|
|
176
|
-
this.emit('error', initError);
|
|
177
|
-
console.error(`[JSONDatabase] FATAL: Initialization failed for ${this.filename}. The database is in an unusable state.`, err);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
this.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
async
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
case '
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
this.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
this.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
this.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1
|
+
// File: JSONDatabase.js
|
|
2
|
+
// Final, Complete, and Secure Version (Patched)
|
|
3
|
+
|
|
4
|
+
const fs = require('fs').promises;
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const _ = require('lodash');
|
|
8
|
+
const EventEmitter = require('events');
|
|
9
|
+
|
|
10
|
+
// --- Custom Error Classes for Better Error Handling ---
|
|
11
|
+
|
|
12
|
+
/** Base error for all database-specific issues. */
|
|
13
|
+
class DBError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = this.constructor.name;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Error during database file initialization or parsing. */
|
|
20
|
+
class DBInitializationError extends DBError {}
|
|
21
|
+
/** Error within a user-provided transaction function. */
|
|
22
|
+
class TransactionError extends DBError {}
|
|
23
|
+
/** Error when data fails schema validation. */
|
|
24
|
+
class ValidationError extends DBError {
|
|
25
|
+
constructor(message, validationIssues) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.issues = validationIssues; // e.g., from Zod/Joi
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Error related to index integrity (e.g., unique constraint violation). */
|
|
31
|
+
class IndexViolationError extends DBError {}
|
|
32
|
+
/** Error for security-related issues like path traversal or bad keys. */
|
|
33
|
+
class SecurityError extends DBError {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
// --- Type Definitions for Clarity ---
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} BatchOperationSet
|
|
40
|
+
* @property {'set'} type
|
|
41
|
+
* @property {string | string[]} path
|
|
42
|
+
* @property {any} value
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} BatchOperationDelete
|
|
47
|
+
* @property {'delete'} type
|
|
48
|
+
* @property {string | string[]} path
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} BatchOperationPush
|
|
53
|
+
* @property {'push'} type
|
|
54
|
+
* @property {string | string[]} path
|
|
55
|
+
* @property {any[]} values - Items to push uniquely using deep comparison.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} BatchOperationPull
|
|
60
|
+
* @property {'pull'} type
|
|
61
|
+
* @property {string | string[]} path
|
|
62
|
+
* @property {any[]} values - Items to remove using deep comparison.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {BatchOperationSet | BatchOperationDelete | BatchOperationPush | BatchOperationPull} BatchOperation
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {object} IndexDefinition
|
|
71
|
+
* @property {string} name - The unique name for the index.
|
|
72
|
+
* @property {string | string[]} path - The lodash path to the collection object (e.g., 'users').
|
|
73
|
+
* @property {string} field - The property field within each collection item to index (e.g., 'email').
|
|
74
|
+
* @property {boolean} [unique=false] - If true, enforces that the indexed field must be unique across the collection.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
// --- Cryptography Constants ---
|
|
79
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
80
|
+
const IV_LENGTH = 16;
|
|
81
|
+
const AUTH_TAG_LENGTH = 16;
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A robust, secure, promise-based JSON file database with atomic operations, indexing, schema validation, and events.
|
|
86
|
+
* Includes encryption-at-rest and path traversal protection.
|
|
87
|
+
*
|
|
88
|
+
* @class JSONDatabase
|
|
89
|
+
* @extends {EventEmitter}
|
|
90
|
+
*/
|
|
91
|
+
class JSONDatabase extends EventEmitter {
|
|
92
|
+
/**
|
|
93
|
+
* Creates a database instance.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} filename - Database file path.
|
|
96
|
+
* @param {object} [options] - Configuration options.
|
|
97
|
+
* @param {string} [options.encryptionKey=null] - A 32-byte (64-character hex) secret key for encryption. If provided, enables encryption-at-rest. **MANAGE THIS KEY SECURELY.**
|
|
98
|
+
* @param {boolean} [options.prettyPrint=false] - Pretty-print JSON output (only if not encrypted).
|
|
99
|
+
* @param {boolean} [options.writeOnChange=true] - Only write to disk if data has changed.
|
|
100
|
+
* @param {object} [options.schema=null] - A validation schema (e.g., from Zod) with a `safeParse` method.
|
|
101
|
+
* @param {IndexDefinition[]} [options.indices=[]] - An array of index definitions for fast lookups.
|
|
102
|
+
* @throws {SecurityError} If the filename is invalid or attempts path traversal.
|
|
103
|
+
* @throws {SecurityError} If an encryption key is provided but is not the correct length.
|
|
104
|
+
*/
|
|
105
|
+
constructor(filename, options = {}) {
|
|
106
|
+
super();
|
|
107
|
+
|
|
108
|
+
// --- Security Check: Path Traversal ---
|
|
109
|
+
const resolvedPath = path.resolve(filename);
|
|
110
|
+
const workingDir = process.cwd();
|
|
111
|
+
if (!resolvedPath.startsWith(workingDir)) {
|
|
112
|
+
throw new SecurityError(`Path traversal detected. Database path must be within the project directory: ${workingDir}`);
|
|
113
|
+
}
|
|
114
|
+
this.filename = /\.json$/.test(resolvedPath) ? resolvedPath : `${resolvedPath}.json`;
|
|
115
|
+
|
|
116
|
+
// --- Security Check: Encryption Key ---
|
|
117
|
+
if (options.encryptionKey && (!options.encryptionKey || Buffer.from(options.encryptionKey, 'hex').length !== 32)) {
|
|
118
|
+
throw new SecurityError('Encryption key must be a 32-byte (64-character hex) string.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.config = {
|
|
122
|
+
prettyPrint: options.prettyPrint === true,
|
|
123
|
+
writeOnChange: options.writeOnChange !== false,
|
|
124
|
+
schema: options.schema || null,
|
|
125
|
+
indices: options.indices || [],
|
|
126
|
+
encryptionKey: options.encryptionKey ? Buffer.from(options.encryptionKey, 'hex') : null,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
this.cache = null;
|
|
130
|
+
this.writeLock = Promise.resolve();
|
|
131
|
+
this.stats = { reads: 0, writes: 0, cacheHits: 0 };
|
|
132
|
+
this._indices = new Map();
|
|
133
|
+
|
|
134
|
+
// Asynchronously initialize. Operations will queue behind this promise.
|
|
135
|
+
this._initPromise = this._initialize();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Encryption & Decryption ---
|
|
139
|
+
_encrypt(data) {
|
|
140
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
141
|
+
const cipher = crypto.createCipheriv(ALGORITHM, this.config.encryptionKey, iv);
|
|
142
|
+
const jsonString = JSON.stringify(data);
|
|
143
|
+
const encrypted = Buffer.concat([cipher.update(jsonString, 'utf8'), cipher.final()]);
|
|
144
|
+
const authTag = cipher.getAuthTag();
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
iv: iv.toString('hex'),
|
|
147
|
+
tag: authTag.toString('hex'),
|
|
148
|
+
content: encrypted.toString('hex'),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_decrypt(encryptedPayload) {
|
|
153
|
+
try {
|
|
154
|
+
const payload = JSON.parse(encryptedPayload);
|
|
155
|
+
const iv = Buffer.from(payload.iv, 'hex');
|
|
156
|
+
const authTag = Buffer.from(payload.tag, 'hex');
|
|
157
|
+
const encryptedContent = Buffer.from(payload.content, 'hex');
|
|
158
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, this.config.encryptionKey, iv);
|
|
159
|
+
decipher.setAuthTag(authTag);
|
|
160
|
+
const decrypted = decipher.update(encryptedContent, 'hex', 'utf8') + decipher.final('utf8');
|
|
161
|
+
return JSON.parse(decrypted);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw new SecurityError('Decryption failed. The file may be corrupted, tampered with, or the encryption key is incorrect.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Private Core Methods ---
|
|
168
|
+
|
|
169
|
+
/** @private Kicks off the initialization process. */
|
|
170
|
+
async _initialize() {
|
|
171
|
+
try {
|
|
172
|
+
await this._refreshCache();
|
|
173
|
+
this._rebuildAllIndices();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const initError = new DBInitializationError(`Failed to initialize database: ${err.message}`);
|
|
176
|
+
this.emit('error', initError);
|
|
177
|
+
console.error(`[JSONDatabase] FATAL: Initialization failed for ${this.filename}. The database is in an unusable state.`, err);
|
|
178
|
+
// --- ENHANCEMENT: Make the instance unusable if init fails ---
|
|
179
|
+
// By re-throwing here, the _initPromise will be rejected, and all subsequent
|
|
180
|
+
// operations waiting on _ensureInitialized() will fail immediately.
|
|
181
|
+
throw initError;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @private Reads file, decrypts if necessary, and populates cache. */
|
|
186
|
+
async _refreshCache() {
|
|
187
|
+
try {
|
|
188
|
+
const fileContent = await fs.readFile(this.filename, 'utf8');
|
|
189
|
+
if (this.config.encryptionKey) {
|
|
190
|
+
this.cache = fileContent.trim() === '' ? {} : this._decrypt(fileContent);
|
|
191
|
+
} else {
|
|
192
|
+
this.cache = fileContent.trim() === '' ? {} : JSON.parse(fileContent);
|
|
193
|
+
}
|
|
194
|
+
this.stats.reads++;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err.code === 'ENOENT') {
|
|
197
|
+
console.warn(`[JSONDatabase] File ${this.filename} not found. Creating.`);
|
|
198
|
+
this.cache = {};
|
|
199
|
+
const initialContent = this.config.encryptionKey ? this._encrypt({}) : '{}';
|
|
200
|
+
await fs.writeFile(this.filename, initialContent, 'utf8');
|
|
201
|
+
this.stats.writes++;
|
|
202
|
+
} else if (err instanceof SyntaxError && !this.config.encryptionKey) {
|
|
203
|
+
throw new DBInitializationError(`Failed to parse JSON from ${this.filename}. File is corrupted.`);
|
|
204
|
+
} else {
|
|
205
|
+
throw err; // Re-throw security, crypto, and other errors
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @private Ensures all operations wait for initialization to complete. */
|
|
211
|
+
async _ensureInitialized() {
|
|
212
|
+
// This promise will be rejected if _initialize() fails, stopping all operations.
|
|
213
|
+
return this._initPromise;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @private Performs an atomic write operation. */
|
|
217
|
+
async _atomicWrite(operationFn) {
|
|
218
|
+
await this._ensureInitialized();
|
|
219
|
+
|
|
220
|
+
// This promise chain ensures all writes happen one after another.
|
|
221
|
+
this.writeLock = this.writeLock.then(async () => {
|
|
222
|
+
// Use the live cache as the source of truth for the transaction.
|
|
223
|
+
const oldData = this.cache;
|
|
224
|
+
const dataToModify = _.cloneDeep(oldData);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// --- CRITICAL FIX: Await the operation function in case it's async ---
|
|
228
|
+
const newData = await operationFn(dataToModify);
|
|
229
|
+
|
|
230
|
+
// --- ENHANCEMENT: Stricter check to prevent accidental data loss ---
|
|
231
|
+
if (newData === undefined) {
|
|
232
|
+
throw new TransactionError("Atomic operation function returned undefined. Aborting to prevent data loss. Did you forget to `return data`?");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (this.config.schema) {
|
|
236
|
+
const validationResult = this.config.schema.safeParse(newData);
|
|
237
|
+
if (!validationResult.success) {
|
|
238
|
+
throw new ValidationError('Schema validation failed.', validationResult.error.issues);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- ENHANCEMENT: Update indices *before* the write to catch violations early ---
|
|
243
|
+
// This will throw an IndexViolationError if there's a problem.
|
|
244
|
+
this._updateIndices(oldData, newData);
|
|
245
|
+
|
|
246
|
+
// Only write to disk if data has actually changed.
|
|
247
|
+
if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
|
|
248
|
+
return oldData; // Return the unchanged data
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const contentToWrite = this.config.encryptionKey
|
|
252
|
+
? this._encrypt(newData)
|
|
253
|
+
: JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
|
|
254
|
+
|
|
255
|
+
await fs.writeFile(this.filename, contentToWrite, 'utf8');
|
|
256
|
+
|
|
257
|
+
// Update cache only after a successful write.
|
|
258
|
+
this.cache = newData;
|
|
259
|
+
this.stats.writes++;
|
|
260
|
+
|
|
261
|
+
this.emit('write', { filename: this.filename, timestamp: Date.now() });
|
|
262
|
+
this.emit('change', { oldValue: oldData, newValue: newData });
|
|
263
|
+
|
|
264
|
+
return newData;
|
|
265
|
+
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// If any part of the transaction fails, emit the error and re-throw.
|
|
268
|
+
// The cache remains unchanged from before the operation.
|
|
269
|
+
this.emit('error', error);
|
|
270
|
+
console.error("[JSONDatabase] Atomic write failed. No changes were saved.", error);
|
|
271
|
+
throw error; // Propagate the error to the caller.
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return this.writeLock;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Indexing ---
|
|
279
|
+
|
|
280
|
+
/** @private Clears and rebuilds all defined indices from the current cache. */
|
|
281
|
+
_rebuildAllIndices() {
|
|
282
|
+
this._indices.clear();
|
|
283
|
+
for (const indexDef of this.config.indices) {
|
|
284
|
+
this._indices.set(indexDef.name, new Map());
|
|
285
|
+
}
|
|
286
|
+
if (this.config.indices.length > 0 && !_.isEmpty(this.cache)) {
|
|
287
|
+
this._updateIndices({}, this.cache); // Treat it as a full "add" operation
|
|
288
|
+
}
|
|
289
|
+
console.log(`[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @private Compares old and new data to update indices efficiently. */
|
|
293
|
+
_updateIndices(oldData, newData) {
|
|
294
|
+
for (const indexDef of this.config.indices) {
|
|
295
|
+
const collectionPath = indexDef.path;
|
|
296
|
+
const field = indexDef.field;
|
|
297
|
+
const indexMap = this._indices.get(indexDef.name);
|
|
298
|
+
|
|
299
|
+
const oldCollection = _.get(oldData, collectionPath, []);
|
|
300
|
+
const newCollection = _.get(newData, collectionPath, []);
|
|
301
|
+
|
|
302
|
+
// This logic works for both arrays of objects and objects of objects (maps)
|
|
303
|
+
const oldItems = _.values(oldCollection);
|
|
304
|
+
const newItems = _.values(newCollection);
|
|
305
|
+
|
|
306
|
+
const oldMap = new Map(oldItems.map(item => [item[field], item]));
|
|
307
|
+
const newMap = new Map(newItems.map(item => [item[field], item]));
|
|
308
|
+
|
|
309
|
+
// Find values that were removed or changed
|
|
310
|
+
for (const [oldValue, oldItem] of oldMap.entries()) {
|
|
311
|
+
if (oldValue !== undefined && !newMap.has(oldValue)) {
|
|
312
|
+
indexMap.delete(oldValue);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Find values that were added or changed
|
|
317
|
+
for (const [newValue, newItem] of newMap.entries()) {
|
|
318
|
+
if (newValue !== undefined && !oldMap.has(newValue)) {
|
|
319
|
+
if (indexDef.unique && indexMap.has(newValue)) {
|
|
320
|
+
throw new IndexViolationError(`Unique index '${indexDef.name}' violated for value '${newValue}'.`);
|
|
321
|
+
}
|
|
322
|
+
// To find the key, we need to iterate, which isn't ideal but necessary here.
|
|
323
|
+
const key = _.findKey(newCollection, {[field]: newValue});
|
|
324
|
+
indexMap.set(newValue, key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
// --- Public API ---
|
|
332
|
+
|
|
333
|
+
async get(path, defaultValue) {
|
|
334
|
+
await this._ensureInitialized();
|
|
335
|
+
this.stats.cacheHits++;
|
|
336
|
+
// --- CRITICAL FIX: Handle undefined/null path to get the entire object ---
|
|
337
|
+
if (path === undefined || path === null) {
|
|
338
|
+
return this.cache;
|
|
339
|
+
}
|
|
340
|
+
return _.get(this.cache, path, defaultValue);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async has(path) {
|
|
344
|
+
await this._ensureInitialized();
|
|
345
|
+
this.stats.cacheHits++;
|
|
346
|
+
return _.has(this.cache, path);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async set(path, value) {
|
|
350
|
+
return this._atomicWrite(data => {
|
|
351
|
+
_.set(data, path, value);
|
|
352
|
+
return data;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async delete(path) {
|
|
357
|
+
let deleted = false;
|
|
358
|
+
await this._atomicWrite(data => {
|
|
359
|
+
deleted = _.unset(data, path);
|
|
360
|
+
return data;
|
|
361
|
+
});
|
|
362
|
+
return deleted;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async push(path, ...items) {
|
|
366
|
+
if (items.length === 0) return;
|
|
367
|
+
return this._atomicWrite(data => {
|
|
368
|
+
const arr = _.get(data, path);
|
|
369
|
+
const targetArray = Array.isArray(arr) ? arr : [];
|
|
370
|
+
items.forEach(item => {
|
|
371
|
+
// Use deep comparison to ensure object uniqueness
|
|
372
|
+
if (!targetArray.some(existing => _.isEqual(existing, item))) {
|
|
373
|
+
targetArray.push(item);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
_.set(data, path, targetArray);
|
|
377
|
+
return data;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async pull(path, ...itemsToRemove) {
|
|
382
|
+
if (itemsToRemove.length === 0) return;
|
|
383
|
+
return this._atomicWrite(data => {
|
|
384
|
+
const arr = _.get(data, path);
|
|
385
|
+
if (Array.isArray(arr)) {
|
|
386
|
+
_.pullAllWith(arr, itemsToRemove, _.isEqual);
|
|
387
|
+
}
|
|
388
|
+
return data;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async transaction(transactionFn) {
|
|
393
|
+
return this._atomicWrite(transactionFn);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async batch(ops, options = { stopOnError: false }) {
|
|
397
|
+
if (!Array.isArray(ops) || ops.length === 0) return;
|
|
398
|
+
|
|
399
|
+
return this._atomicWrite(data => {
|
|
400
|
+
for (const [index, op] of ops.entries()) {
|
|
401
|
+
try {
|
|
402
|
+
if (!op || !op.type || op.path === undefined) throw new Error("Invalid operation format: missing type or path.");
|
|
403
|
+
|
|
404
|
+
switch (op.type) {
|
|
405
|
+
case 'set':
|
|
406
|
+
if (!op.hasOwnProperty('value')) throw new Error("Set operation missing 'value'.");
|
|
407
|
+
_.set(data, op.path, op.value);
|
|
408
|
+
break;
|
|
409
|
+
case 'delete':
|
|
410
|
+
_.unset(data, op.path);
|
|
411
|
+
break;
|
|
412
|
+
case 'push':
|
|
413
|
+
if (!Array.isArray(op.values)) throw new Error("Push operation 'values' must be an array.");
|
|
414
|
+
const arr = _.get(data, op.path);
|
|
415
|
+
const targetArray = Array.isArray(arr) ? arr : [];
|
|
416
|
+
op.values.forEach(item => {
|
|
417
|
+
if (!targetArray.some(existing => _.isEqual(existing, item))) targetArray.push(item);
|
|
418
|
+
});
|
|
419
|
+
_.set(data, op.path, targetArray);
|
|
420
|
+
break;
|
|
421
|
+
case 'pull':
|
|
422
|
+
if (!Array.isArray(op.values)) throw new Error("Pull operation 'values' must be an array.");
|
|
423
|
+
const pullArr = _.get(data, op.path);
|
|
424
|
+
if (Array.isArray(pullArr)) _.pullAllWith(pullArr, op.values, _.isEqual);
|
|
425
|
+
break;
|
|
426
|
+
default:
|
|
427
|
+
throw new Error(`Unsupported operation type: '${op.type}'.`);
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const errorMessage = `[JSONDatabase] Batch failed at operation index ${index} (type: ${op?.type}): ${err.message}`;
|
|
431
|
+
if (options.stopOnError) {
|
|
432
|
+
throw new Error(errorMessage);
|
|
433
|
+
} else {
|
|
434
|
+
console.error(errorMessage);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return data;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async find(collectionPath, predicate) {
|
|
443
|
+
await this._ensureInitialized();
|
|
444
|
+
const collection = _.get(this.cache, collectionPath);
|
|
445
|
+
// Works for both objects and arrays
|
|
446
|
+
if (typeof collection !== 'object' || collection === null) return undefined;
|
|
447
|
+
|
|
448
|
+
this.stats.cacheHits++;
|
|
449
|
+
return _.find(collection, predicate);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async findByIndex(indexName, value) {
|
|
453
|
+
await this._ensureInitialized();
|
|
454
|
+
if (!this._indices.has(indexName)) {
|
|
455
|
+
throw new Error(`Index with name '${indexName}' does not exist.`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.stats.cacheHits++;
|
|
459
|
+
const indexMap = this._indices.get(indexName);
|
|
460
|
+
const objectKey = indexMap.get(value);
|
|
461
|
+
|
|
462
|
+
if (objectKey === undefined) return undefined;
|
|
463
|
+
|
|
464
|
+
const indexDef = this.config.indices.find(i => i.name === indexName);
|
|
465
|
+
// Construct the full path to the object
|
|
466
|
+
const fullPath = [..._.toPath(indexDef.path), objectKey];
|
|
467
|
+
return _.get(this.cache, fullPath);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async clear() {
|
|
471
|
+
console.warn(`[JSONDatabase] Clearing all data from ${this.filename}. This action is irreversible.`);
|
|
472
|
+
return this._atomicWrite(() => ({}));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
getStats() {
|
|
476
|
+
return { ...this.stats };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async close() {
|
|
480
|
+
// Wait for the last pending write operation to finish
|
|
481
|
+
await this.writeLock;
|
|
482
|
+
|
|
483
|
+
this.cache = null;
|
|
484
|
+
this._indices.clear();
|
|
485
|
+
this.removeAllListeners();
|
|
486
|
+
this._initPromise = null; // Allow for garbage collection
|
|
487
|
+
|
|
488
|
+
const finalStats = JSON.stringify(this.getStats());
|
|
489
|
+
console.log(`[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
485
493
|
module.exports = JSONDatabase;
|
package/LICENSE
CHANGED
|
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED