json-database-st 1.0.2 → 1.0.5
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 +485 -627
- package/package.json +1 -1
package/JSONDatabase.js
CHANGED
|
@@ -1,627 +1,485 @@
|
|
|
1
|
-
// File: JSONDatabase.js
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
console.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
throw
|
|
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
|
-
|
|
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
|
-
async
|
|
347
|
-
await this.
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
case
|
|
418
|
-
if (op.
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Queries the database based on a predicate function.
|
|
488
|
-
* Iterates over the direct properties (key-value pairs) of an object specified by `options.basePath`,
|
|
489
|
-
* or the root object of the database if `basePath` is omitted.
|
|
490
|
-
* Returns an array of the **values** of the properties that satisfy the predicate.
|
|
491
|
-
*
|
|
492
|
-
* @param {(value: any, key: string) => boolean} predicate - A function returning true for items to include. Receives (value, key) of each property being iterated.
|
|
493
|
-
* @param {object} [options] - Query options.
|
|
494
|
-
* @param {string | string[]} [options.basePath] - A lodash path to the object whose properties should be queried.
|
|
495
|
-
* @param {number} [options.limit] - Maximum number of results to return.
|
|
496
|
-
* @returns {Promise<any[]>} An array of **values** that satisfy the predicate.
|
|
497
|
-
* @example
|
|
498
|
-
* // Find all users older than 30 within the 'users' object
|
|
499
|
-
* const oldUsers = await db.query(
|
|
500
|
-
* (userValue, userKey) => typeof userValue === 'object' && userValue.age > 30,
|
|
501
|
-
* { basePath: 'users' }
|
|
502
|
-
* );
|
|
503
|
-
* // -> [ { name: 'Alice', age: 35, ... }, { name: 'Charlie', age: 40, ... } ]
|
|
504
|
-
*
|
|
505
|
-
* // Find top-level properties whose key starts with 'config'
|
|
506
|
-
* const configValues = await db.query(
|
|
507
|
-
* (value, key) => key.startsWith('config')
|
|
508
|
-
* );
|
|
509
|
-
* // -> [ { theme: 'dark', ... }, { region: 'us-east', ... } ] (assuming config objects exist at root)
|
|
510
|
-
*/
|
|
511
|
-
async query(predicate, options = {}) {
|
|
512
|
-
await this._init(); // Ensure cache is ready
|
|
513
|
-
const data = this.cache;
|
|
514
|
-
this.stats.cacheHits++; // Count query as cache hit
|
|
515
|
-
|
|
516
|
-
const basePath = options.basePath;
|
|
517
|
-
// Use _.get to safely retrieve the base object/value
|
|
518
|
-
const baseData = basePath ? _.get(data, basePath) : data;
|
|
519
|
-
|
|
520
|
-
// Ensure baseData is an object we can iterate over
|
|
521
|
-
if (
|
|
522
|
-
typeof baseData !== "object" ||
|
|
523
|
-
baseData === null ||
|
|
524
|
-
Array.isArray(baseData)
|
|
525
|
-
) {
|
|
526
|
-
if (basePath) {
|
|
527
|
-
console.warn(
|
|
528
|
-
`[JSONDatabase] Query basePath "${basePath}" does not point to an iterable object (must be a plain object). Returning empty array.`
|
|
529
|
-
);
|
|
530
|
-
} else {
|
|
531
|
-
console.warn(
|
|
532
|
-
`[JSONDatabase] Query attempted on non-object root data type (${typeof baseData}). Returning empty array.`
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
return [];
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const results = [];
|
|
539
|
-
const limit = options.limit ?? Infinity;
|
|
540
|
-
|
|
541
|
-
// Iterate over the properties of the baseData object
|
|
542
|
-
for (const key in baseData) {
|
|
543
|
-
// Ensure we only iterate own properties
|
|
544
|
-
if (Object.prototype.hasOwnProperty.call(baseData, key)) {
|
|
545
|
-
if (results.length >= limit) {
|
|
546
|
-
break; // Stop iteration if limit is reached
|
|
547
|
-
}
|
|
548
|
-
const value = baseData[key];
|
|
549
|
-
try {
|
|
550
|
-
if (predicate(value, key)) {
|
|
551
|
-
results.push(value); // Return the value that matched
|
|
552
|
-
}
|
|
553
|
-
} catch (predicateError) {
|
|
554
|
-
console.error(
|
|
555
|
-
`[JSONDatabase] Error executing query predicate for key "${key}":`,
|
|
556
|
-
predicateError
|
|
557
|
-
);
|
|
558
|
-
// Skip this item or handle error as needed (currently skipping)
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Slice again in case limit was exactly reached (though break should handle it)
|
|
564
|
-
return results.slice(0, limit);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* Clears the entire database content, replacing it with an empty object `{}`.
|
|
569
|
-
* This is an atomic write operation. Use with caution!
|
|
570
|
-
* @returns {Promise<void>} Resolves when the database has been cleared.
|
|
571
|
-
*/
|
|
572
|
-
async clear() {
|
|
573
|
-
await this._atomicWrite(() => {
|
|
574
|
-
// Return an empty object to be written
|
|
575
|
-
return {};
|
|
576
|
-
});
|
|
577
|
-
console.warn(`[JSONDatabase] Cleared all data from ${this.filename}.`);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* Returns the current operational statistics for this database instance.
|
|
582
|
-
* @returns {{reads: number, writes: number, cacheHits: number}}
|
|
583
|
-
*/
|
|
584
|
-
getStats() {
|
|
585
|
-
// Return a copy to prevent external modification of the internal stats object
|
|
586
|
-
return { ...this.stats };
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Waits for any pending write operations queued by this instance to complete,
|
|
591
|
-
* then clears the internal cache and releases the write lock.
|
|
592
|
-
* Does not delete the database file.
|
|
593
|
-
* Call this before your application exits to ensure data integrity.
|
|
594
|
-
*
|
|
595
|
-
* @returns {Promise<void>} Resolves when the database instance is closed and pending writes are finished.
|
|
596
|
-
*/
|
|
597
|
-
async close() {
|
|
598
|
-
// Wait for the current write lock promise chain to complete, if any exists
|
|
599
|
-
try {
|
|
600
|
-
// If there's a lock, await it. If not, nothing to wait for.
|
|
601
|
-
if (this.writeLock) {
|
|
602
|
-
await this.writeLock;
|
|
603
|
-
}
|
|
604
|
-
} catch (err) {
|
|
605
|
-
// Log error during final write if it occurs, but proceed with closing
|
|
606
|
-
console.error(
|
|
607
|
-
"[JSONDatabase] Error during final write operation while closing:",
|
|
608
|
-
err
|
|
609
|
-
);
|
|
610
|
-
} finally {
|
|
611
|
-
// Clear the cache and the lock reference *after* waiting
|
|
612
|
-
this.cache = null;
|
|
613
|
-
this.writeLock = null;
|
|
614
|
-
this._initPromise = null; // Reset init promise tracking
|
|
615
|
-
const stats = this.getStats(); // Get stats before resetting
|
|
616
|
-
console.log(
|
|
617
|
-
`[JSONDatabase] Closed connection to ${
|
|
618
|
-
this.filename
|
|
619
|
-
}. Final Stats: ${JSON.stringify(stats)}`
|
|
620
|
-
);
|
|
621
|
-
// Optionally reset stats, or leave them for inspection after close
|
|
622
|
-
// this.stats = { reads: 0, writes: 0, cacheHits: 0 };
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
module.exports = JSONDatabase;
|
|
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
|
+
throw initError;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @private Reads file, decrypts if necessary, and populates cache. */
|
|
183
|
+
async _refreshCache() {
|
|
184
|
+
try {
|
|
185
|
+
const fileContent = await fs.readFile(this.filename, 'utf8');
|
|
186
|
+
if (this.config.encryptionKey) {
|
|
187
|
+
this.cache = fileContent.trim() === '' ? {} : this._decrypt(fileContent);
|
|
188
|
+
} else {
|
|
189
|
+
this.cache = fileContent.trim() === '' ? {} : JSON.parse(fileContent);
|
|
190
|
+
}
|
|
191
|
+
this.stats.reads++;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err.code === 'ENOENT') {
|
|
194
|
+
console.warn(`[JSONDatabase] File ${this.filename} not found. Creating.`);
|
|
195
|
+
this.cache = {};
|
|
196
|
+
const initialContent = this.config.encryptionKey ? this._encrypt({}) : '{}';
|
|
197
|
+
await fs.writeFile(this.filename, initialContent, 'utf8');
|
|
198
|
+
this.stats.writes++;
|
|
199
|
+
} else if (err instanceof SyntaxError && !this.config.encryptionKey) {
|
|
200
|
+
throw new DBInitializationError(`Failed to parse JSON from ${this.filename}. File is corrupted.`);
|
|
201
|
+
} else {
|
|
202
|
+
throw err; // Re-throw security, crypto, and other errors
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @private Ensures all operations wait for initialization to complete. */
|
|
208
|
+
async _ensureInitialized() {
|
|
209
|
+
return this._initPromise;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @private Performs an atomic write operation. */
|
|
213
|
+
async _atomicWrite(operationFn) {
|
|
214
|
+
await this._ensureInitialized();
|
|
215
|
+
|
|
216
|
+
this.writeLock = this.writeLock.then(async () => {
|
|
217
|
+
const oldData = this.cache;
|
|
218
|
+
const dataToModify = _.cloneDeep(oldData);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const newData = operationFn(dataToModify);
|
|
222
|
+
if (newData === undefined) {
|
|
223
|
+
throw new TransactionError("Atomic operation function returned undefined. Aborting to prevent data loss.");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.config.schema) {
|
|
227
|
+
const validationResult = this.config.schema.safeParse(newData);
|
|
228
|
+
if (!validationResult.success) {
|
|
229
|
+
throw new ValidationError('Schema validation failed.', validationResult.error.issues);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
|
|
234
|
+
return oldData;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this._updateIndices(oldData, newData);
|
|
238
|
+
|
|
239
|
+
const contentToWrite = this.config.encryptionKey
|
|
240
|
+
? this._encrypt(newData)
|
|
241
|
+
: JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
|
|
242
|
+
|
|
243
|
+
await fs.writeFile(this.filename, contentToWrite, 'utf8');
|
|
244
|
+
|
|
245
|
+
this.cache = newData;
|
|
246
|
+
this.stats.writes++;
|
|
247
|
+
|
|
248
|
+
this.emit('write', { filename: this.filename, timestamp: Date.now() });
|
|
249
|
+
this.emit('change', { oldValue: oldData, newValue: newData });
|
|
250
|
+
|
|
251
|
+
return newData;
|
|
252
|
+
|
|
253
|
+
} catch (error) {
|
|
254
|
+
this.emit('error', error);
|
|
255
|
+
console.error("[JSONDatabase] Atomic write failed. No changes were saved.", error);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return this.writeLock;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Indexing ---
|
|
264
|
+
|
|
265
|
+
/** @private Clears and rebuilds all defined indices from the current cache. */
|
|
266
|
+
_rebuildAllIndices() {
|
|
267
|
+
this._indices.clear();
|
|
268
|
+
for (const indexDef of this.config.indices) {
|
|
269
|
+
this._indices.set(indexDef.name, new Map());
|
|
270
|
+
}
|
|
271
|
+
if (this.config.indices.length > 0 && !_.isEmpty(this.cache)) {
|
|
272
|
+
this._updateIndices({}, this.cache); // Treat it as a full "add" operation
|
|
273
|
+
}
|
|
274
|
+
console.log(`[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @private Compares old and new data to update indices efficiently. */
|
|
278
|
+
_updateIndices(oldData, newData) {
|
|
279
|
+
for (const indexDef of this.config.indices) {
|
|
280
|
+
const collectionPath = indexDef.path;
|
|
281
|
+
const field = indexDef.field;
|
|
282
|
+
const indexMap = this._indices.get(indexDef.name);
|
|
283
|
+
|
|
284
|
+
const oldCollection = _.get(oldData, collectionPath, {});
|
|
285
|
+
const newCollection = _.get(newData, collectionPath, {});
|
|
286
|
+
|
|
287
|
+
const oldKeys = Object.keys(oldCollection);
|
|
288
|
+
const newKeys = Object.keys(newCollection);
|
|
289
|
+
|
|
290
|
+
const addedKeys = _.difference(newKeys, oldKeys);
|
|
291
|
+
const removedKeys = _.difference(oldKeys, newKeys);
|
|
292
|
+
const potentiallyModifiedKeys = _.intersection(oldKeys, newKeys);
|
|
293
|
+
|
|
294
|
+
for (const key of removedKeys) {
|
|
295
|
+
const oldItem = oldCollection[key];
|
|
296
|
+
if (oldItem && oldItem[field] !== undefined) {
|
|
297
|
+
indexMap.delete(oldItem[field]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const key of addedKeys) {
|
|
302
|
+
const newItem = newCollection[key];
|
|
303
|
+
const indexValue = newItem?.[field];
|
|
304
|
+
if (indexValue !== undefined) {
|
|
305
|
+
if (indexDef.unique && indexMap.has(indexValue)) {
|
|
306
|
+
throw new IndexViolationError(`Unique index '${indexDef.name}' violated for value '${indexValue}'.`);
|
|
307
|
+
}
|
|
308
|
+
indexMap.set(indexValue, key);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const key of potentiallyModifiedKeys) {
|
|
313
|
+
const oldItem = oldCollection[key];
|
|
314
|
+
const newItem = newCollection[key];
|
|
315
|
+
const oldIndexValue = oldItem?.[field];
|
|
316
|
+
const newIndexValue = newItem?.[field];
|
|
317
|
+
|
|
318
|
+
if (!_.isEqual(oldItem, newItem) && oldIndexValue !== newIndexValue) {
|
|
319
|
+
if (oldIndexValue !== undefined) indexMap.delete(oldIndexValue);
|
|
320
|
+
if (newIndexValue !== undefined) {
|
|
321
|
+
if (indexDef.unique && indexMap.has(newIndexValue)) {
|
|
322
|
+
throw new IndexViolationError(`Unique index '${indexDef.name}' violated for value '${newIndexValue}'.`);
|
|
323
|
+
}
|
|
324
|
+
indexMap.set(newIndexValue, key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
// --- Public API ---
|
|
333
|
+
|
|
334
|
+
async get(path, defaultValue) {
|
|
335
|
+
await this._ensureInitialized();
|
|
336
|
+
this.stats.cacheHits++;
|
|
337
|
+
return _.get(this.cache, path, defaultValue);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async has(path) {
|
|
341
|
+
await this._ensureInitialized();
|
|
342
|
+
this.stats.cacheHits++;
|
|
343
|
+
return _.has(this.cache, path);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async set(path, value) {
|
|
347
|
+
await this._atomicWrite(data => {
|
|
348
|
+
_.set(data, path, value);
|
|
349
|
+
return data;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async delete(path) {
|
|
354
|
+
let deleted = false;
|
|
355
|
+
await this._atomicWrite(data => {
|
|
356
|
+
deleted = _.unset(data, path);
|
|
357
|
+
return data;
|
|
358
|
+
});
|
|
359
|
+
return deleted;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async push(path, ...items) {
|
|
363
|
+
if (items.length === 0) return;
|
|
364
|
+
await this._atomicWrite(data => {
|
|
365
|
+
const arr = _.get(data, path);
|
|
366
|
+
const targetArray = Array.isArray(arr) ? arr : [];
|
|
367
|
+
items.forEach(item => {
|
|
368
|
+
if (!targetArray.some(existing => _.isEqual(existing, item))) {
|
|
369
|
+
targetArray.push(item);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
_.set(data, path, targetArray);
|
|
373
|
+
return data;
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async pull(path, ...itemsToRemove) {
|
|
378
|
+
if (itemsToRemove.length === 0) return;
|
|
379
|
+
await this._atomicWrite(data => {
|
|
380
|
+
const arr = _.get(data, path);
|
|
381
|
+
if (Array.isArray(arr)) {
|
|
382
|
+
_.pullAllWith(arr, itemsToRemove, _.isEqual);
|
|
383
|
+
}
|
|
384
|
+
return data;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async transaction(transactionFn) {
|
|
389
|
+
return this._atomicWrite(transactionFn);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async batch(ops, options = { stopOnError: false }) {
|
|
393
|
+
if (!Array.isArray(ops) || ops.length === 0) return;
|
|
394
|
+
|
|
395
|
+
await this._atomicWrite(data => {
|
|
396
|
+
for (const [index, op] of ops.entries()) {
|
|
397
|
+
try {
|
|
398
|
+
if (!op || !op.type || op.path === undefined) throw new Error("Invalid operation format: missing type or path.");
|
|
399
|
+
|
|
400
|
+
switch (op.type) {
|
|
401
|
+
case 'set':
|
|
402
|
+
if (!op.hasOwnProperty('value')) throw new Error("Set operation missing 'value'.");
|
|
403
|
+
_.set(data, op.path, op.value);
|
|
404
|
+
break;
|
|
405
|
+
case 'delete':
|
|
406
|
+
_.unset(data, op.path);
|
|
407
|
+
break;
|
|
408
|
+
case 'push':
|
|
409
|
+
if (!Array.isArray(op.values)) throw new Error("Push operation 'values' must be an array.");
|
|
410
|
+
const arr = _.get(data, op.path);
|
|
411
|
+
const targetArray = Array.isArray(arr) ? arr : [];
|
|
412
|
+
op.values.forEach(item => {
|
|
413
|
+
if (!targetArray.some(existing => _.isEqual(existing, item))) targetArray.push(item);
|
|
414
|
+
});
|
|
415
|
+
_.set(data, op.path, targetArray);
|
|
416
|
+
break;
|
|
417
|
+
case 'pull':
|
|
418
|
+
if (!Array.isArray(op.values)) throw new Error("Pull operation 'values' must be an array.");
|
|
419
|
+
const pullArr = _.get(data, op.path);
|
|
420
|
+
if (Array.isArray(pullArr)) _.pullAllWith(pullArr, op.values, _.isEqual);
|
|
421
|
+
break;
|
|
422
|
+
default:
|
|
423
|
+
throw new Error(`Unsupported operation type: '${op.type}'.`);
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const errorMessage = `[JSONDatabase] Batch failed at operation index ${index} (type: ${op?.type}): ${err.message}`;
|
|
427
|
+
if (options.stopOnError) {
|
|
428
|
+
throw new Error(errorMessage);
|
|
429
|
+
} else {
|
|
430
|
+
console.error(errorMessage);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return data;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async find(collectionPath, predicate) {
|
|
439
|
+
await this._ensureInitialized();
|
|
440
|
+
const collection = _.get(this.cache, collectionPath);
|
|
441
|
+
if (typeof collection !== 'object' || collection === null) return undefined;
|
|
442
|
+
|
|
443
|
+
this.stats.cacheHits++;
|
|
444
|
+
return _.find(collection, predicate);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async findByIndex(indexName, value) {
|
|
448
|
+
await this._ensureInitialized();
|
|
449
|
+
if (!this._indices.has(indexName)) {
|
|
450
|
+
throw new Error(`Index with name '${indexName}' does not exist.`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.stats.cacheHits++;
|
|
454
|
+
const indexMap = this._indices.get(indexName);
|
|
455
|
+
const objectKey = indexMap.get(value);
|
|
456
|
+
|
|
457
|
+
if (objectKey === undefined) return undefined;
|
|
458
|
+
|
|
459
|
+
const indexDef = this.config.indices.find(i => i.name === indexName);
|
|
460
|
+
return _.get(this.cache, [..._.toPath(indexDef.path), objectKey]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async clear() {
|
|
464
|
+
console.warn(`[JSONDatabase] Clearing all data from ${this.filename}.`);
|
|
465
|
+
await this._atomicWrite(() => ({}));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
getStats() {
|
|
469
|
+
return { ...this.stats };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async close() {
|
|
473
|
+
await this.writeLock;
|
|
474
|
+
|
|
475
|
+
this.cache = null;
|
|
476
|
+
this._indices.clear();
|
|
477
|
+
this.removeAllListeners();
|
|
478
|
+
this._initPromise = null;
|
|
479
|
+
|
|
480
|
+
const finalStats = JSON.stringify(this.getStats());
|
|
481
|
+
console.log(`[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = JSONDatabase;
|
package/package.json
CHANGED