json-database-st 1.0.11 → 1.0.12

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.
Files changed (3) hide show
  1. package/JSONDatabase.js +649 -592
  2. package/README.md +144 -135
  3. package/package.json +48 -46
package/JSONDatabase.js CHANGED
@@ -1,592 +1,649 @@
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
- const lockfile = require("proper-lockfile");
10
-
11
- // --- Custom Error Classes for Better Error Handling ---
12
-
13
- /** Base error for all database-specific issues. */
14
- class DBError extends Error {
15
- constructor(message) {
16
- super(message);
17
- this.name = this.constructor.name;
18
- }
19
- }
20
- /** Error during database file initialization or parsing. */
21
- class DBInitializationError extends DBError { }
22
- /** Error within a user-provided transaction function. */
23
- class TransactionError extends DBError { }
24
- /** Error when data fails schema validation. */
25
- class ValidationError extends DBError {
26
- constructor(message, validationIssues) {
27
- super(message);
28
- this.issues = validationIssues; // e.g., from Zod/Joi
29
- }
30
- }
31
- /** Error related to index integrity (e.g., unique constraint violation). */
32
- class IndexViolationError extends DBError { }
33
- /** Error for security-related issues like path traversal or bad keys. */
34
- class SecurityError extends DBError { }
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
- // --- Cryptography Constants ---
78
- const ALGORITHM = "aes-256-gcm";
79
- const IV_LENGTH = 16;
80
- const AUTH_TAG_LENGTH = 16;
81
-
82
- /**
83
- * A robust, secure, promise-based JSON file database with atomic operations, indexing, schema validation, and events.
84
- * Includes encryption-at-rest and path traversal protection.
85
- *
86
- * @class JSONDatabase
87
- * @extends {EventEmitter}
88
- */
89
- class JSONDatabase extends EventEmitter {
90
- /**
91
- * Creates a database instance.
92
- *
93
- * @param {string} filename - Database file path.
94
- * @param {object} [options] - Configuration options.
95
- * @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.**
96
- * @param {boolean} [options.prettyPrint=false] - Pretty-print JSON output (only if not encrypted).
97
- * @param {boolean} [options.writeOnChange=true] - Only write to disk if data has changed.
98
- * @param {object} [options.schema=null] - A validation schema (e.g., from Zod) with a `safeParse` method.
99
- * @param {IndexDefinition[]} [options.indices=[]] - An array of index definitions for fast lookups.
100
- * @throws {SecurityError} If the filename is invalid or attempts path traversal.
101
- * @throws {SecurityError} If an encryption key is provided but is not the correct length.
102
- */
103
- constructor(filename, options = {}) {
104
- super();
105
-
106
- // --- Security Check: Path Traversal ---
107
- const resolvedPath = path.resolve(filename);
108
- const workingDir = process.cwd();
109
- if (!resolvedPath.startsWith(workingDir)) {
110
- throw new SecurityError(
111
- `Path traversal detected. Database path must be within the project directory: ${workingDir}`
112
- );
113
- }
114
- this.filename = /\.json$/.test(resolvedPath)
115
- ? resolvedPath
116
- : `${resolvedPath}.json`;
117
-
118
- // --- Security Check: Encryption Key ---
119
- if (
120
- options.encryptionKey &&
121
- (!options.encryptionKey ||
122
- Buffer.from(options.encryptionKey, "hex").length !== 32)
123
- ) {
124
- throw new SecurityError(
125
- "Encryption key must be a 32-byte (64-character hex) string."
126
- );
127
- }
128
-
129
- this.config = {
130
- prettyPrint: options.prettyPrint === true,
131
- writeOnChange: options.writeOnChange !== false,
132
- schema: options.schema || null,
133
- indices: options.indices || [],
134
- encryptionKey: options.encryptionKey
135
- ? Buffer.from(options.encryptionKey, "hex")
136
- : null,
137
- };
138
-
139
- this.cache = null;
140
- this.writeLock = Promise.resolve();
141
- this.stats = { reads: 0, writes: 0, cacheHits: 0 };
142
- this._indices = new Map();
143
-
144
- // Asynchronously initialize. Operations will queue behind this promise.
145
- this._initPromise = this._initialize();
146
- }
147
-
148
- // --- Encryption & Decryption ---
149
- _encrypt(data) {
150
- const iv = crypto.randomBytes(IV_LENGTH);
151
- const cipher = crypto.createCipheriv(
152
- ALGORITHM,
153
- this.config.encryptionKey,
154
- iv
155
- );
156
- const jsonString = JSON.stringify(data);
157
- const encrypted = Buffer.concat([
158
- cipher.update(jsonString, "utf8"),
159
- cipher.final(),
160
- ]);
161
- const authTag = cipher.getAuthTag();
162
- return JSON.stringify({
163
- iv: iv.toString("hex"),
164
- tag: authTag.toString("hex"),
165
- content: encrypted.toString("hex"),
166
- });
167
- }
168
-
169
- _decrypt(encryptedPayload) {
170
- try {
171
- const payload = JSON.parse(encryptedPayload);
172
- const iv = Buffer.from(payload.iv, "hex");
173
- const authTag = Buffer.from(payload.tag, "hex");
174
- const encryptedContent = Buffer.from(payload.content, "hex");
175
- const decipher = crypto.createDecipheriv(
176
- ALGORITHM,
177
- this.config.encryptionKey,
178
- iv
179
- );
180
- decipher.setAuthTag(authTag);
181
- const decrypted =
182
- decipher.update(encryptedContent, "hex", "utf8") +
183
- decipher.final("utf8");
184
- return JSON.parse(decrypted);
185
- } catch (e) {
186
- throw new SecurityError(
187
- "Decryption failed. The file may be corrupted, tampered with, or the encryption key is incorrect."
188
- );
189
- }
190
- }
191
-
192
- // --- Private Core Methods ---
193
-
194
- /** @private Kicks off the initialization process. */
195
- async _initialize() {
196
- // --- FIX: Crash Recovery for Durable Writes ---
197
- // Check if a temporary file exists from a previously failed write.
198
- // If so, it represents the most recent state. We recover by renaming it.
199
- const tempFile = this.filename + ".tmp";
200
- try {
201
- await fs.access(tempFile);
202
- console.warn(
203
- `[JSONDatabase] Found temporary file ${tempFile}. Recovering from a previous failed write.`
204
- );
205
- await fs.rename(tempFile, this.filename);
206
- console.log(
207
- `[JSONDatabase] Recovery successful. ${this.filename} has been restored.`
208
- );
209
- } catch (e) {
210
- // This is the normal case where no temp file exists. Do nothing.
211
- }
212
-
213
- try {
214
- await this._refreshCache();
215
- this._rebuildAllIndices();
216
- } catch (err) {
217
- const initError = new DBInitializationError(
218
- `Failed to initialize database: ${err.message}`
219
- );
220
- this.emit("error", initError);
221
- console.error(
222
- `[JSONDatabase] FATAL: Initialization failed for ${this.filename}. The database is in an unusable state.`,
223
- err
224
- );
225
- // --- ENHANCEMENT: Make the instance unusable if init fails ---
226
- // By re-throwing here, the _initPromise will be rejected, and all subsequent
227
- // operations waiting on _ensureInitialized() will fail immediately.
228
- throw initError;
229
- }
230
- }
231
-
232
- /** @private Reads file, decrypts if necessary, and populates cache. */
233
- async _refreshCache() {
234
- try {
235
- const fileContent = await fs.readFile(this.filename, "utf8");
236
- if (this.config.encryptionKey) {
237
- this.cache =
238
- fileContent.trim() === "" ? {} : this._decrypt(fileContent);
239
- } else {
240
- this.cache = fileContent.trim() === "" ? {} : JSON.parse(fileContent);
241
- }
242
- this.stats.reads++;
243
- } catch (err) {
244
- if (err.code === "ENOENT") {
245
- console.warn(
246
- `[JSONDatabase] File ${this.filename} not found. Creating.`
247
- );
248
- this.cache = {};
249
- // Do not write file here; _atomicWrite will create it safely.
250
- } else if (err instanceof SyntaxError && !this.config.encryptionKey) {
251
- throw new DBInitializationError(
252
- `Failed to parse JSON from ${this.filename}. File is corrupted.`
253
- );
254
- } else {
255
- throw err; // Re-throw security, crypto, and other errors
256
- }
257
- }
258
- }
259
-
260
- /** @private Ensures all operations wait for initialization to complete. */
261
- async _ensureInitialized() {
262
- // This promise will be rejected if _initialize() fails, stopping all operations.
263
- return this._initPromise;
264
- }
265
-
266
- /** @private Performs an atomic write operation. */
267
- async _atomicWrite(operationFn) {
268
- await this._ensureInitialized();
269
-
270
- // This promise chain ensures all writes *from this process* happen one after another.
271
- this.writeLock = this.writeLock.then(async () => {
272
- let releaseLock;
273
- try {
274
- // --- FIX: Ensure the file exists before locking it.
275
- await fs.access(this.filename).catch(() => fs.writeFile(this.filename, this.config.encryptionKey ? this._encrypt({}) : '{}', 'utf8'));
276
-
277
- // --- FIX: Acquire a cross-process lock to prevent race conditions.
278
- // This will wait if another process (or this one) currently holds the lock.
279
- releaseLock = await lockfile.lock(this.filename, {
280
- stale: 7000, // Lock is considered stale after 7s
281
- retries: {
282
- retries: 5,
283
- factor: 1.2,
284
- minTimeout: 200,
285
- },
286
- });
287
-
288
- // --- FIX: Refresh cache *after* acquiring the lock.
289
- // This is critical to get the latest data if another process changed it.
290
- await this._refreshCache();
291
-
292
- const oldData = this.cache;
293
- const dataToModify = _.cloneDeep(oldData);
294
-
295
- const newData = await operationFn(dataToModify);
296
-
297
- if (newData === undefined) {
298
- throw new TransactionError(
299
- "Atomic operation function returned undefined. Aborting to prevent data loss. Did you forget to `return data`?"
300
- );
301
- }
302
-
303
- if (this.config.schema) {
304
- const validationResult = this.config.schema.safeParse(newData);
305
- if (!validationResult.success) {
306
- throw new ValidationError(
307
- "Schema validation failed.",
308
- validationResult.error.issues
309
- );
310
- }
311
- }
312
-
313
- this._updateIndices(oldData, newData);
314
-
315
- if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
316
- return oldData; // Return the unchanged data
317
- }
318
-
319
- const contentToWrite = this.config.encryptionKey
320
- ? this._encrypt(newData)
321
- : JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
322
-
323
- // --- FIX: Implement durable write. Write to temp file first.
324
- const tempFile = this.filename + ".tmp";
325
- await fs.writeFile(tempFile, contentToWrite, "utf8");
326
- // --- FIX: Atomically rename temp file to the final filename.
327
- await fs.rename(tempFile, this.filename);
328
-
329
- this.cache = newData;
330
- this.stats.writes++;
331
-
332
- this.emit("write", { filename: this.filename, timestamp: Date.now() });
333
- this.emit("change", { oldValue: oldData, newValue: newData });
334
-
335
- return newData;
336
- } catch (error) {
337
- this.emit("error", error);
338
- console.error(
339
- "[JSONDatabase] Atomic write failed. No changes were saved.",
340
- error
341
- );
342
- throw error;
343
- } finally {
344
- // --- FIX: Always release the lock, even if an error occurred.
345
- if (releaseLock) {
346
- await releaseLock();
347
- }
348
- }
349
- });
350
-
351
- return this.writeLock;
352
- }
353
-
354
- // --- Indexing ---
355
-
356
- /** @private Clears and rebuilds all defined indices from the current cache. */
357
- _rebuildAllIndices() {
358
- this._indices.clear();
359
- for (const indexDef of this.config.indices) {
360
- this._indices.set(indexDef.name, new Map());
361
- }
362
- if (this.config.indices.length > 0 && !_.isEmpty(this.cache)) {
363
- // Rebuild by treating the current state as "new" and the previous state as empty.
364
- this._updateIndices({}, this.cache);
365
- }
366
- console.log(
367
- `[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`
368
- );
369
- }
370
-
371
- /**
372
- * @private Compares old and new data to update indices efficiently.
373
- * FIX: Replaced inefficient and buggy index update logic with a robust key-based comparison.
374
- * This new implementation correctly handles additions, deletions, and in-place updates,
375
- * and is significantly more performant.
376
- */
377
- _updateIndices(oldData, newData) {
378
- for (const indexDef of this.config.indices) {
379
- const indexMap = this._indices.get(indexDef.name);
380
- if (!indexMap) continue;
381
-
382
- const oldCollection = _.get(oldData, indexDef.path, {});
383
- const newCollection = _.get(newData, indexDef.path, {});
384
-
385
- if (!_.isObject(oldCollection) || !_.isObject(newCollection)) {
386
- continue; // Indexing requires a collection (object or array).
387
- }
388
-
389
- const allKeys = _.union(_.keys(oldCollection), _.keys(newCollection));
390
-
391
- for (const key of allKeys) {
392
- const oldItem = oldCollection[key];
393
- const newItem = newCollection[key];
394
-
395
- if (_.isEqual(oldItem, newItem)) {
396
- continue; // Item is unchanged, no index update needed.
397
- }
398
-
399
- const oldVal = oldItem?.[indexDef.field];
400
- const newVal = newItem?.[indexDef.field];
401
-
402
- if (_.isEqual(oldVal, newVal)) {
403
- continue; // Indexed field's value is unchanged.
404
- }
405
-
406
- // 1. Remove the old value if it was indexed and pointed to this item.
407
- if (oldVal !== undefined && indexMap.get(oldVal) === key) {
408
- indexMap.delete(oldVal);
409
- }
410
-
411
- // 2. Add the new value if it's defined.
412
- if (newVal !== undefined) {
413
- // Check for unique constraint violation before adding.
414
- if (indexDef.unique && indexMap.has(newVal)) {
415
- throw new IndexViolationError(
416
- `Unique index '${indexDef.name}' violated for value '${newVal}'.`
417
- );
418
- }
419
- indexMap.set(newVal, key);
420
- }
421
- }
422
- }
423
- }
424
-
425
- // --- Public API ---
426
-
427
- async get(path, defaultValue) {
428
- await this._ensureInitialized();
429
- this.stats.cacheHits++;
430
- if (path === undefined || path === null) {
431
- return this.cache;
432
- }
433
- return _.get(this.cache, path, defaultValue);
434
- }
435
-
436
- async has(path) {
437
- await this._ensureInitialized();
438
- this.stats.cacheHits++;
439
- return _.has(this.cache, path);
440
- }
441
-
442
- async set(path, value) {
443
- return this._atomicWrite((data) => {
444
- _.set(data, path, value);
445
- return data;
446
- });
447
- }
448
-
449
- async delete(path) {
450
- let deleted = false;
451
- await this._atomicWrite((data) => {
452
- deleted = _.unset(data, path);
453
- return data;
454
- });
455
- return deleted;
456
- }
457
-
458
- async push(path, ...items) {
459
- if (items.length === 0) return;
460
- return this._atomicWrite((data) => {
461
- const arr = _.get(data, path);
462
- const targetArray = Array.isArray(arr) ? arr : [];
463
- items.forEach((item) => {
464
- if (!targetArray.some((existing) => _.isEqual(existing, item))) {
465
- targetArray.push(item);
466
- }
467
- });
468
- _.set(data, path, targetArray);
469
- return data;
470
- });
471
- }
472
-
473
- async pull(path, ...itemsToRemove) {
474
- if (itemsToRemove.length === 0) return;
475
- return this._atomicWrite((data) => {
476
- const arr = _.get(data, path);
477
- if (Array.isArray(arr)) {
478
- _.pullAllWith(arr, itemsToRemove, _.isEqual);
479
- }
480
- return data;
481
- });
482
- }
483
-
484
- async transaction(transactionFn) {
485
- return this._atomicWrite(transactionFn);
486
- }
487
-
488
- async batch(ops, options = { stopOnError: false }) {
489
- if (!Array.isArray(ops) || ops.length === 0) return;
490
-
491
- return this._atomicWrite((data) => {
492
- for (const [index, op] of ops.entries()) {
493
- try {
494
- if (!op || !op.type || op.path === undefined)
495
- throw new Error("Invalid operation format: missing type or path.");
496
-
497
- switch (op.type) {
498
- case "set":
499
- if (!op.hasOwnProperty("value"))
500
- throw new Error("Set operation missing 'value'.");
501
- _.set(data, op.path, op.value);
502
- break;
503
- case "delete":
504
- _.unset(data, op.path);
505
- break;
506
- case "push":
507
- if (!Array.isArray(op.values))
508
- throw new Error("Push operation 'values' must be an array.");
509
- const arr = _.get(data, op.path);
510
- const targetArray = Array.isArray(arr) ? arr : [];
511
- op.values.forEach((item) => {
512
- if (!targetArray.some((existing) => _.isEqual(existing, item)))
513
- targetArray.push(item);
514
- });
515
- _.set(data, op.path, targetArray);
516
- break;
517
- case "pull":
518
- if (!Array.isArray(op.values))
519
- throw new Error("Pull operation 'values' must be an array.");
520
- const pullArr = _.get(data, op.path);
521
- if (Array.isArray(pullArr))
522
- _.pullAllWith(pullArr, op.values, _.isEqual);
523
- break;
524
- default:
525
- throw new Error(`Unsupported operation type: '${op.type}'.`);
526
- }
527
- } catch (err) {
528
- const errorMessage = `[JSONDatabase] Batch failed at operation index ${index} (type: ${op?.type}): ${err.message}`;
529
- if (options.stopOnError) {
530
- throw new Error(errorMessage);
531
- } else {
532
- console.error(errorMessage);
533
- }
534
- }
535
- }
536
- return data;
537
- });
538
- }
539
-
540
- async find(collectionPath, predicate) {
541
- await this._ensureInitialized();
542
- const collection = _.get(this.cache, collectionPath);
543
- if (typeof collection !== "object" || collection === null) return undefined;
544
-
545
- this.stats.cacheHits++;
546
- return _.find(collection, predicate);
547
- }
548
-
549
- async findByIndex(indexName, value) {
550
- await this._ensureInitialized();
551
- if (!this._indices.has(indexName)) {
552
- throw new Error(`Index with name '${indexName}' does not exist.`);
553
- }
554
-
555
- this.stats.cacheHits++;
556
- const indexMap = this._indices.get(indexName);
557
- const objectKey = indexMap.get(value);
558
-
559
- if (objectKey === undefined) return undefined;
560
-
561
- const indexDef = this.config.indices.find((i) => i.name === indexName);
562
- const fullPath = [..._.toPath(indexDef.path), objectKey];
563
- return _.get(this.cache, fullPath);
564
- }
565
-
566
- async clear() {
567
- console.warn(
568
- `[JSONDatabase] Clearing all data from ${this.filename}. This action is irreversible.`
569
- );
570
- return this._atomicWrite(() => ({}));
571
- }
572
-
573
- getStats() {
574
- return { ...this.stats };
575
- }
576
-
577
- async close() {
578
- await this.writeLock;
579
-
580
- this.cache = null;
581
- this._indices.clear();
582
- this.removeAllListeners();
583
- this._initPromise = null;
584
-
585
- const finalStats = JSON.stringify(this.getStats());
586
- console.log(
587
- `[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`
588
- );
589
- }
590
- }
591
-
592
- module.exports = JSONDatabase;
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
+ const lockfile = require("proper-lockfile");
10
+
11
+ // --- Custom Error Classes for Better Error Handling ---
12
+
13
+ /** Base error for all database-specific issues. */
14
+ class DBError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = this.constructor.name;
18
+ }
19
+ }
20
+ /** Error during database file initialization or parsing. */
21
+ class DBInitializationError extends DBError { }
22
+ /** Error within a user-provided transaction function. */
23
+ class TransactionError extends DBError { }
24
+ /** Error when data fails schema validation. */
25
+ class ValidationError extends DBError {
26
+ constructor(message, validationIssues) {
27
+ super(message);
28
+ this.issues = validationIssues; // e.g., from Zod/Joi
29
+ }
30
+ }
31
+ /** Error related to index integrity (e.g., unique constraint violation). */
32
+ class IndexViolationError extends DBError { }
33
+ /** Error for security-related issues like path traversal or bad keys. */
34
+ class SecurityError extends DBError { }
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
+ // --- Cryptography Constants ---
78
+ const ALGORITHM = "aes-256-gcm";
79
+ const IV_LENGTH = 16;
80
+ const AUTH_TAG_LENGTH = 16;
81
+
82
+ /**
83
+ * A robust, secure, promise-based JSON file database with atomic operations, indexing, schema validation, and events.
84
+ * Includes encryption-at-rest and path traversal protection.
85
+ *
86
+ * @class JSONDatabase
87
+ * @extends {EventEmitter}
88
+ */
89
+ class JSONDatabase extends EventEmitter {
90
+ /**
91
+ * Creates a database instance.
92
+ *
93
+ * @param {string} filename - Database file path.
94
+ * @param {object} [options] - Configuration options.
95
+ * @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.**
96
+ * @param {boolean} [options.prettyPrint=false] - Pretty-print JSON output (only if not encrypted).
97
+ * @param {boolean} [options.writeOnChange=true] - Only write to disk if data has changed.
98
+ * @param {object} [options.schema=null] - A validation schema (e.g., from Zod) with a `safeParse` method.
99
+ * @param {IndexDefinition[]} [options.indices=[]] - An array of index definitions for fast lookups.
100
+ * @throws {SecurityError} If the filename is invalid or attempts path traversal.
101
+ * @throws {SecurityError} If an encryption key is provided but is not the correct length.
102
+ */
103
+ constructor(filename, options = {}) {
104
+ super();
105
+
106
+ // --- Security Check: Path Traversal ---
107
+ const resolvedPath = path.resolve(filename);
108
+ const workingDir = process.cwd();
109
+ if (!resolvedPath.startsWith(workingDir)) {
110
+ throw new SecurityError(
111
+ `Path traversal detected. Database path must be within the project directory: ${workingDir}`
112
+ );
113
+ }
114
+ this.filename = /\.json$/.test(resolvedPath)
115
+ ? resolvedPath
116
+ : `${resolvedPath}.json`;
117
+
118
+ // --- Security Check: Encryption Key ---
119
+ if (
120
+ options.encryptionKey &&
121
+ (!options.encryptionKey ||
122
+ Buffer.from(options.encryptionKey, "hex").length !== 32)
123
+ ) {
124
+ throw new SecurityError(
125
+ "Encryption key must be a 32-byte (64-character hex) string."
126
+ );
127
+ }
128
+
129
+ this.config = {
130
+ prettyPrint: options.prettyPrint === true,
131
+ writeOnChange: options.writeOnChange !== false,
132
+ schema: options.schema || null,
133
+ indices: options.indices || [],
134
+ encryptionKey: options.encryptionKey
135
+ ? Buffer.from(options.encryptionKey, "hex")
136
+ : null,
137
+ };
138
+
139
+ this.cache = null;
140
+ this.writeLock = Promise.resolve();
141
+ this.stats = { reads: 0, writes: 0, cacheHits: 0 };
142
+ this._indices = new Map();
143
+ this._middleware = {
144
+ before: { set: [], delete: [], push: [], pull: [], transaction: [], batch: [] },
145
+ after: { set: [], delete: [], push: [], pull: [], transaction: [], batch: [] }
146
+ };
147
+
148
+ // Asynchronously initialize. Operations will queue behind this promise.
149
+ this._initPromise = this._initialize();
150
+ }
151
+
152
+ // --- Middleware ---
153
+
154
+ /**
155
+ * Registers a middleware function to run before an operation.
156
+ * @param {'set'|'delete'|'push'|'pull'|'transaction'|'batch'} operation - The operation type.
157
+ * @param {string} pathPattern - A lodash path with optional wildcards (e.g., 'users.*.name').
158
+ * @param {Function} callback - The middleware function. It receives an object with context (path, value, etc.) and can modify it.
159
+ */
160
+ before(operation, pathPattern, callback) {
161
+ if (this._middleware.before[operation]) {
162
+ const regex = new RegExp(`^${pathPattern.replace(/\*/g, '[^.]+').replace(/\./g, '\.')}$`);
163
+ this._middleware.before[operation].push({ regex, callback });
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Registers a middleware function to run after an operation.
169
+ * @param {'set'|'delete'|'push'|'pull'|'transaction'|'batch'} operation - The operation type.
170
+ * @param {string} pathPattern - A lodash path with optional wildcards (e.g., 'users.*.name').
171
+ * @param {Function} callback - The middleware function. It receives an object with context (path, value, finalData).
172
+ */
173
+ after(operation, pathPattern, callback) {
174
+ if (this._middleware.after[operation]) {
175
+ const regex = new RegExp(`^${pathPattern.replace(/\*/g, '[^.]+').replace(/\./g, '\.')}$`);
176
+ this._middleware.after[operation].push({ regex, callback });
177
+ }
178
+ }
179
+
180
+ /**
181
+ * @private
182
+ * Executes middleware for a given hook and operation.
183
+ */
184
+ _runMiddleware(hook, operation, context) {
185
+ const middlewares = this._middleware[hook][operation] || [];
186
+ let modifiedContext = context;
187
+
188
+ for (const { regex, callback } of middlewares) {
189
+ if (regex.test(modifiedContext.path)) {
190
+ modifiedContext = callback(modifiedContext);
191
+ if (hook === 'before' && !modifiedContext) {
192
+ throw new Error(`Middleware for ${operation} on path ${context.path} must return a context object.`);
193
+ }
194
+ }
195
+ }
196
+ return modifiedContext;
197
+ }
198
+
199
+ // --- Encryption & Decryption ---
200
+ _encrypt(data) {
201
+ const iv = crypto.randomBytes(IV_LENGTH);
202
+ const cipher = crypto.createCipheriv(
203
+ ALGORITHM,
204
+ this.config.encryptionKey,
205
+ iv
206
+ );
207
+ const jsonString = JSON.stringify(data);
208
+ const encrypted = Buffer.concat([
209
+ cipher.update(jsonString, "utf8"),
210
+ cipher.final(),
211
+ ]);
212
+ const authTag = cipher.getAuthTag();
213
+ return JSON.stringify({
214
+ iv: iv.toString("hex"),
215
+ tag: authTag.toString("hex"),
216
+ content: encrypted.toString("hex"),
217
+ });
218
+ }
219
+
220
+ _decrypt(encryptedPayload) {
221
+ try {
222
+ const payload = JSON.parse(encryptedPayload);
223
+ const iv = Buffer.from(payload.iv, "hex");
224
+ const authTag = Buffer.from(payload.tag, "hex");
225
+ const encryptedContent = Buffer.from(payload.content, "hex");
226
+ const decipher = crypto.createDecipheriv(
227
+ ALGORITHM,
228
+ this.config.encryptionKey,
229
+ iv
230
+ );
231
+ decipher.setAuthTag(authTag);
232
+ const decrypted =
233
+ decipher.update(encryptedContent, "hex", "utf8") +
234
+ decipher.final("utf8");
235
+ return JSON.parse(decrypted);
236
+ } catch (e) {
237
+ throw new SecurityError(
238
+ "Decryption failed. The file may be corrupted, tampered with, or the encryption key is incorrect."
239
+ );
240
+ }
241
+ }
242
+
243
+ // --- Private Core Methods ---
244
+
245
+ /** @private Kicks off the initialization process. */
246
+ async _initialize() {
247
+ // --- FIX: Crash Recovery for Durable Writes ---
248
+ // Check if a temporary file exists from a previously failed write.
249
+ // If so, it represents the most recent state. We recover by renaming it.
250
+ const tempFile = this.filename + ".tmp";
251
+ try {
252
+ await fs.access(tempFile);
253
+ console.warn(
254
+ `[JSONDatabase] Found temporary file ${tempFile}. Recovering from a previous failed write.`
255
+ );
256
+ await fs.rename(tempFile, this.filename);
257
+ console.log(
258
+ `[JSONDatabase] Recovery successful. ${this.filename} has been restored.`
259
+ );
260
+ } catch (e) {
261
+ // This is the normal case where no temp file exists. Do nothing.
262
+ }
263
+
264
+ try {
265
+ await this._refreshCache();
266
+ this._rebuildAllIndices();
267
+ } catch (err) {
268
+ const initError = new DBInitializationError(
269
+ `Failed to initialize database: ${err.message}`
270
+ );
271
+ this.emit("error", initError);
272
+ console.error(
273
+ `[JSONDatabase] FATAL: Initialization failed for ${this.filename}. The database is in an unusable state.`,
274
+ err
275
+ );
276
+ // --- ENHANCEMENT: Make the instance unusable if init fails ---
277
+ // By re-throwing here, the _initPromise will be rejected, and all subsequent
278
+ // operations waiting on _ensureInitialized() will fail immediately.
279
+ throw initError;
280
+ }
281
+ }
282
+
283
+ /** @private Reads file, decrypts if necessary, and populates cache. */
284
+ async _refreshCache() {
285
+ try {
286
+ const fileContent = await fs.readFile(this.filename, "utf8");
287
+ if (this.config.encryptionKey) {
288
+ this.cache =
289
+ fileContent.trim() === "" ? {} : this._decrypt(fileContent);
290
+ } else {
291
+ this.cache = fileContent.trim() === "" ? {} : JSON.parse(fileContent);
292
+ }
293
+ this.stats.reads++;
294
+ } catch (err) {
295
+ if (err.code === "ENOENT") {
296
+ console.warn(
297
+ `[JSONDatabase] File ${this.filename} not found. Creating.`
298
+ );
299
+ this.cache = {};
300
+ // Do not write file here; _atomicWrite will create it safely.
301
+ } else if (err instanceof SyntaxError && !this.config.encryptionKey) {
302
+ throw new DBInitializationError(
303
+ `Failed to parse JSON from ${this.filename}. File is corrupted.`
304
+ );
305
+ } else {
306
+ throw err; // Re-throw security, crypto, and other errors
307
+ }
308
+ }
309
+ }
310
+
311
+ /** @private Ensures all operations wait for initialization to complete. */
312
+ async _ensureInitialized() {
313
+ // This promise will be rejected if _initialize() fails, stopping all operations.
314
+ return this._initPromise;
315
+ }
316
+
317
+ /** @private Performs an atomic write operation. */
318
+ async _atomicWrite(operationFn) {
319
+ await this._ensureInitialized();
320
+
321
+ const executeWrite = async () => {
322
+ let releaseLock;
323
+ try {
324
+ await fs.access(this.filename).catch(() => fs.writeFile(this.filename, this.config.encryptionKey ? this._encrypt({}) : '{}', 'utf8'));
325
+
326
+ releaseLock = await lockfile.lock(this.filename, {
327
+ stale: 7000,
328
+ retries: { retries: 5, factor: 1.2, minTimeout: 200 },
329
+ });
330
+
331
+ await this._refreshCache();
332
+ const oldData = this.cache;
333
+ const dataToModify = _.cloneDeep(oldData);
334
+ const newData = await operationFn(dataToModify);
335
+
336
+ if (newData === undefined) {
337
+ throw new TransactionError("Atomic operation function returned undefined. Aborting to prevent data loss. Did you forget to `return data`?");
338
+ }
339
+
340
+ if (this.config.schema) {
341
+ const validationResult = this.config.schema.safeParse(newData);
342
+ if (!validationResult.success) {
343
+ throw new ValidationError("Schema validation failed.", validationResult.error.issues);
344
+ }
345
+ }
346
+
347
+ this._updateIndices(oldData, newData);
348
+
349
+ if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
350
+ return oldData;
351
+ }
352
+
353
+ const contentToWrite = this.config.encryptionKey
354
+ ? this._encrypt(newData)
355
+ : JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
356
+
357
+ const tempFile = this.filename + ".tmp";
358
+ await fs.writeFile(tempFile, contentToWrite, "utf8");
359
+ await fs.rename(tempFile, this.filename);
360
+
361
+ this.cache = newData;
362
+ this.stats.writes++;
363
+ this.emit("write", { filename: this.filename, timestamp: Date.now() });
364
+ this.emit("change", { oldValue: oldData, newValue: newData });
365
+
366
+ return newData;
367
+ } catch (error) {
368
+ this.emit("error", error);
369
+ // Do not log here, let the caller handle the error.
370
+ throw error;
371
+ } finally {
372
+ if (releaseLock) {
373
+ await releaseLock();
374
+ }
375
+ }
376
+ };
377
+
378
+ // Create a promise for the current operation.
379
+ const operationPromise = this.writeLock.then(() => executeWrite());
380
+ // Prevent the main chain from breaking on a rejection, allowing subsequent writes.
381
+ this.writeLock = operationPromise.catch(() => {});
382
+ // Return the promise for the current operation so the caller can handle its specific result/error.
383
+ return operationPromise;
384
+ }
385
+
386
+ // --- Indexing ---
387
+
388
+ /** @private Clears and rebuilds all defined indices from the current cache. */
389
+ _rebuildAllIndices() {
390
+ this._indices.clear();
391
+ for (const indexDef of this.config.indices) {
392
+ this._indices.set(indexDef.name, new Map());
393
+ }
394
+ if (this.config.indices.length > 0 && !_.isEmpty(this.cache)) {
395
+ // Rebuild by treating the current state as "new" and the previous state as empty.
396
+ this._updateIndices({}, this.cache);
397
+ }
398
+ console.log(
399
+ `[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`
400
+ );
401
+ }
402
+
403
+ /**
404
+ * @private Compares old and new data to update indices efficiently.
405
+ * FIX: Replaced inefficient and buggy index update logic with a robust key-based comparison.
406
+ * This new implementation correctly handles additions, deletions, and in-place updates,
407
+ * and is significantly more performant.
408
+ */
409
+ _updateIndices(oldData, newData) {
410
+ for (const indexDef of this.config.indices) {
411
+ const indexMap = this._indices.get(indexDef.name);
412
+ if (!indexMap) continue;
413
+
414
+ const oldCollection = _.get(oldData, indexDef.path, {});
415
+ const newCollection = _.get(newData, indexDef.path, {});
416
+
417
+ if (!_.isObject(oldCollection) || !_.isObject(newCollection)) {
418
+ continue; // Indexing requires a collection (object or array).
419
+ }
420
+
421
+ const allKeys = _.union(_.keys(oldCollection), _.keys(newCollection));
422
+
423
+ for (const key of allKeys) {
424
+ const oldItem = oldCollection[key];
425
+ const newItem = newCollection[key];
426
+
427
+ if (_.isEqual(oldItem, newItem)) {
428
+ continue; // Item is unchanged, no index update needed.
429
+ }
430
+
431
+ const oldVal = oldItem?.[indexDef.field];
432
+ const newVal = newItem?.[indexDef.field];
433
+
434
+ if (_.isEqual(oldVal, newVal)) {
435
+ continue; // Indexed field's value is unchanged.
436
+ }
437
+
438
+ // 1. Remove the old value if it was indexed and pointed to this item.
439
+ if (oldVal !== undefined && indexMap.get(oldVal) === key) {
440
+ indexMap.delete(oldVal);
441
+ }
442
+
443
+ // 2. Add the new value if it's defined.
444
+ if (newVal !== undefined) {
445
+ // Check for unique constraint violation before adding.
446
+ if (indexDef.unique && indexMap.has(newVal)) {
447
+ throw new IndexViolationError(
448
+ `Unique index '${indexDef.name}' violated for value '${newVal}'.`
449
+ );
450
+ }
451
+ indexMap.set(newVal, key);
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // --- Public API ---
458
+
459
+ async get(path, defaultValue) {
460
+ await this._ensureInitialized();
461
+ this.stats.cacheHits++;
462
+ if (path === undefined || path === null) {
463
+ return this.cache;
464
+ }
465
+ return _.get(this.cache, path, defaultValue);
466
+ }
467
+
468
+ async has(path) {
469
+ await this._ensureInitialized();
470
+ this.stats.cacheHits++;
471
+ return _.has(this.cache, path);
472
+ }
473
+
474
+ async set(path, value) {
475
+ let context = { path, value };
476
+ context = this._runMiddleware('before', 'set', context);
477
+
478
+ const result = await this._atomicWrite((data) => {
479
+ _.set(data, context.path, context.value);
480
+ return data;
481
+ });
482
+
483
+ this._runMiddleware('after', 'set', { ...context, finalData: this.cache });
484
+ return result;
485
+ }
486
+
487
+ async delete(path) {
488
+ let context = { path };
489
+ context = this._runMiddleware('before', 'delete', context);
490
+
491
+ let deleted = false;
492
+ const result = await this._atomicWrite((data) => {
493
+ deleted = _.unset(data, context.path);
494
+ return data;
495
+ });
496
+
497
+ this._runMiddleware('after', 'delete', { ...context, finalData: this.cache });
498
+ return deleted;
499
+ }
500
+
501
+ async push(path, ...items) {
502
+ if (items.length === 0) return;
503
+
504
+ let context = { path, items };
505
+ context = this._runMiddleware('before', 'push', context);
506
+
507
+ const result = await this._atomicWrite((data) => {
508
+ const arr = _.get(data, context.path);
509
+ const targetArray = Array.isArray(arr) ? arr : [];
510
+ context.items.forEach((item) => {
511
+ if (!targetArray.some((existing) => _.isEqual(existing, item))) {
512
+ targetArray.push(item);
513
+ }
514
+ });
515
+ _.set(data, context.path, targetArray);
516
+ return data;
517
+ });
518
+
519
+ this._runMiddleware('after', 'push', { ...context, finalData: this.cache });
520
+ return result;
521
+ }
522
+
523
+ async pull(path, ...itemsToRemove) {
524
+ if (itemsToRemove.length === 0) return;
525
+
526
+ let context = { path, itemsToRemove };
527
+ context = this._runMiddleware('before', 'pull', context);
528
+
529
+ const result = await this._atomicWrite((data) => {
530
+ const arr = _.get(data, context.path);
531
+ if (Array.isArray(arr)) {
532
+ _.pullAllWith(arr, context.itemsToRemove, _.isEqual);
533
+ }
534
+ return data;
535
+ });
536
+
537
+ this._runMiddleware('after', 'pull', { ...context, finalData: this.cache });
538
+ return result;
539
+ }
540
+
541
+ async transaction(transactionFn) {
542
+ return this._atomicWrite(transactionFn);
543
+ }
544
+
545
+ async batch(ops, options = { stopOnError: false }) {
546
+ if (!Array.isArray(ops) || ops.length === 0) return;
547
+
548
+ return this._atomicWrite((data) => {
549
+ for (const [index, op] of ops.entries()) {
550
+ try {
551
+ if (!op || !op.type || op.path === undefined)
552
+ throw new Error("Invalid operation format: missing type or path.");
553
+
554
+ switch (op.type) {
555
+ case "set":
556
+ if (!op.hasOwnProperty("value"))
557
+ throw new Error("Set operation missing 'value'.");
558
+ _.set(data, op.path, op.value);
559
+ break;
560
+ case "delete":
561
+ _.unset(data, op.path);
562
+ break;
563
+ case "push":
564
+ if (!Array.isArray(op.values))
565
+ throw new Error("Push operation 'values' must be an array.");
566
+ const arr = _.get(data, op.path);
567
+ const targetArray = Array.isArray(arr) ? arr : [];
568
+ op.values.forEach((item) => {
569
+ if (!targetArray.some((existing) => _.isEqual(existing, item)))
570
+ targetArray.push(item);
571
+ });
572
+ _.set(data, op.path, targetArray);
573
+ break;
574
+ case "pull":
575
+ if (!Array.isArray(op.values))
576
+ throw new Error("Pull operation 'values' must be an array.");
577
+ const pullArr = _.get(data, op.path);
578
+ if (Array.isArray(pullArr))
579
+ _.pullAllWith(pullArr, op.values, _.isEqual);
580
+ break;
581
+ default:
582
+ throw new Error(`Unsupported operation type: '${op.type}'.`);
583
+ }
584
+ } catch (err) {
585
+ const errorMessage = `[JSONDatabase] Batch failed at operation index ${index} (type: ${op?.type}): ${err.message}`;
586
+ if (options.stopOnError) {
587
+ throw new Error(errorMessage);
588
+ } else {
589
+ console.error(errorMessage);
590
+ }
591
+ }
592
+ }
593
+ return data;
594
+ });
595
+ }
596
+
597
+ async find(collectionPath, predicate) {
598
+ await this._ensureInitialized();
599
+ const collection = _.get(this.cache, collectionPath);
600
+ if (typeof collection !== "object" || collection === null) return undefined;
601
+
602
+ this.stats.cacheHits++;
603
+ return _.find(collection, predicate);
604
+ }
605
+
606
+ async findByIndex(indexName, value) {
607
+ await this._ensureInitialized();
608
+ if (!this._indices.has(indexName)) {
609
+ throw new Error(`Index with name '${indexName}' does not exist.`);
610
+ }
611
+
612
+ this.stats.cacheHits++;
613
+ const indexMap = this._indices.get(indexName);
614
+ const objectKey = indexMap.get(value);
615
+
616
+ if (objectKey === undefined) return undefined;
617
+
618
+ const indexDef = this.config.indices.find((i) => i.name === indexName);
619
+ const fullPath = [..._.toPath(indexDef.path), objectKey];
620
+ return _.get(this.cache, fullPath);
621
+ }
622
+
623
+ async clear() {
624
+ console.warn(
625
+ `[JSONDatabase] Clearing all data from ${this.filename}. This action is irreversible.`
626
+ );
627
+ return this._atomicWrite(() => ({}));
628
+ }
629
+
630
+ getStats() {
631
+ return { ...this.stats };
632
+ }
633
+
634
+ async close() {
635
+ await this.writeLock;
636
+
637
+ this.cache = null;
638
+ this._indices.clear();
639
+ this.removeAllListeners();
640
+ this._initPromise = null;
641
+
642
+ const finalStats = JSON.stringify(this.getStats());
643
+ console.log(
644
+ `[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`
645
+ );
646
+ }
647
+ }
648
+
649
+ module.exports = JSONDatabase;