json-database-st 1.0.10 → 1.0.11

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 (2) hide show
  1. package/JSONDatabase.js +478 -475
  2. package/package.json +1 -1
package/JSONDatabase.js CHANGED
@@ -12,26 +12,26 @@ const lockfile = require("proper-lockfile");
12
12
 
13
13
  /** Base error for all database-specific issues. */
14
14
  class DBError extends Error {
15
- constructor(message) {
16
- super(message);
17
- this.name = this.constructor.name;
18
- }
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = this.constructor.name;
18
+ }
19
19
  }
20
20
  /** Error during database file initialization or parsing. */
21
- class DBInitializationError extends DBError {}
21
+ class DBInitializationError extends DBError { }
22
22
  /** Error within a user-provided transaction function. */
23
- class TransactionError extends DBError {}
23
+ class TransactionError extends DBError { }
24
24
  /** Error when data fails schema validation. */
25
25
  class ValidationError extends DBError {
26
- constructor(message, validationIssues) {
27
- super(message);
28
- this.issues = validationIssues; // e.g., from Zod/Joi
29
- }
26
+ constructor(message, validationIssues) {
27
+ super(message);
28
+ this.issues = validationIssues; // e.g., from Zod/Joi
29
+ }
30
30
  }
31
31
  /** Error related to index integrity (e.g., unique constraint violation). */
32
- class IndexViolationError extends DBError {}
32
+ class IndexViolationError extends DBError { }
33
33
  /** Error for security-related issues like path traversal or bad keys. */
34
- class SecurityError extends DBError {}
34
+ class SecurityError extends DBError { }
35
35
 
36
36
  // --- Type Definitions for Clarity ---
37
37
 
@@ -87,503 +87,506 @@ const AUTH_TAG_LENGTH = 16;
87
87
  * @extends {EventEmitter}
88
88
  */
89
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
- );
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();
113
146
  }
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
- );
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
+ });
127
167
  }
128
168
 
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
- );
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
+ }
189
190
  }
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.
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
+ }
211
230
  }
212
231
 
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;
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
+ }
229
258
  }
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
- }
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;
257
264
  }
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: Acquire a cross-process lock to prevent race conditions.
275
- // This will wait if another process (or this one) currently holds the lock.
276
- releaseLock = await lockfile.lock(this.filename, {
277
- stale: 7000, // Lock is considered stale after 7s
278
- retries: {
279
- retries: 5,
280
- factor: 1.2,
281
- minTimeout: 200,
282
- },
283
- });
284
265
 
285
- // --- FIX: Refresh cache *after* acquiring the lock.
286
- // This is critical to get the latest data if another process changed it.
287
- await this._refreshCache();
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
+ });
288
350
 
289
- const oldData = this.cache;
290
- const dataToModify = _.cloneDeep(oldData);
351
+ return this.writeLock;
352
+ }
291
353
 
292
- const newData = await operationFn(dataToModify);
354
+ // --- Indexing ---
293
355
 
294
- if (newData === undefined) {
295
- throw new TransactionError(
296
- "Atomic operation function returned undefined. Aborting to prevent data loss. Did you forget to `return data`?"
297
- );
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());
298
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
+ }
299
370
 
300
- if (this.config.schema) {
301
- const validationResult = this.config.schema.safeParse(newData);
302
- if (!validationResult.success) {
303
- throw new ValidationError(
304
- "Schema validation failed.",
305
- validationResult.error.issues
306
- );
307
- }
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
+ }
308
422
  }
423
+ }
309
424
 
310
- this._updateIndices(oldData, newData);
425
+ // --- Public API ---
311
426
 
312
- if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
313
- return oldData; // Return the unchanged data
427
+ async get(path, defaultValue) {
428
+ await this._ensureInitialized();
429
+ this.stats.cacheHits++;
430
+ if (path === undefined || path === null) {
431
+ return this.cache;
314
432
  }
433
+ return _.get(this.cache, path, defaultValue);
434
+ }
315
435
 
316
- const contentToWrite = this.config.encryptionKey
317
- ? this._encrypt(newData)
318
- : JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
436
+ async has(path) {
437
+ await this._ensureInitialized();
438
+ this.stats.cacheHits++;
439
+ return _.has(this.cache, path);
440
+ }
319
441
 
320
- // --- FIX: Implement durable write. Write to temp file first.
321
- const tempFile = this.filename + ".tmp";
322
- await fs.writeFile(tempFile, contentToWrite, "utf8");
323
- // --- FIX: Atomically rename temp file to the final filename.
324
- await fs.rename(tempFile, this.filename);
325
-
326
- this.cache = newData;
327
- this.stats.writes++;
328
-
329
- this.emit("write", { filename: this.filename, timestamp: Date.now() });
330
- this.emit("change", { oldValue: oldData, newValue: newData });
331
-
332
- return newData;
333
- } catch (error) {
334
- this.emit("error", error);
335
- console.error(
336
- "[JSONDatabase] Atomic write failed. No changes were saved.",
337
- error
338
- );
339
- throw error;
340
- } finally {
341
- // --- FIX: Always release the lock, even if an error occurred.
342
- if (releaseLock) {
343
- await releaseLock();
344
- }
345
- }
346
- });
442
+ async set(path, value) {
443
+ return this._atomicWrite((data) => {
444
+ _.set(data, path, value);
445
+ return data;
446
+ });
447
+ }
347
448
 
348
- return this.writeLock;
349
- }
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
+ }
350
457
 
351
- // --- Indexing ---
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
+ }
352
472
 
353
- /** @private Clears and rebuilds all defined indices from the current cache. */
354
- _rebuildAllIndices() {
355
- this._indices.clear();
356
- for (const indexDef of this.config.indices) {
357
- this._indices.set(indexDef.name, new Map());
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
+ });
358
482
  }
359
- if (this.config.indices.length > 0 && !_.isEmpty(this.cache)) {
360
- // Rebuild by treating the current state as "new" and the previous state as empty.
361
- this._updateIndices({}, this.cache);
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
+ });
362
538
  }
363
- console.log(
364
- `[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`
365
- );
366
- }
367
-
368
- /**
369
- * @private Compares old and new data to update indices efficiently.
370
- * FIX: Replaced inefficient and buggy index update logic with a robust key-based comparison.
371
- * This new implementation correctly handles additions, deletions, and in-place updates,
372
- * and is significantly more performant.
373
- */
374
- _updateIndices(oldData, newData) {
375
- for (const indexDef of this.config.indices) {
376
- const indexMap = this._indices.get(indexDef.name);
377
- if (!indexMap) continue;
378
-
379
- const oldCollection = _.get(oldData, indexDef.path, {});
380
- const newCollection = _.get(newData, indexDef.path, {});
381
-
382
- if (!_.isObject(oldCollection) || !_.isObject(newCollection)) {
383
- continue; // Indexing requires a collection (object or array).
384
- }
385
-
386
- const allKeys = _.union(_.keys(oldCollection), _.keys(newCollection));
387
-
388
- for (const key of allKeys) {
389
- const oldItem = oldCollection[key];
390
- const newItem = newCollection[key];
391
-
392
- if (_.isEqual(oldItem, newItem)) {
393
- continue; // Item is unchanged, no index update needed.
394
- }
395
539
 
396
- const oldVal = oldItem?.[indexDef.field];
397
- const newVal = newItem?.[indexDef.field];
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;
398
544
 
399
- if (_.isEqual(oldVal, newVal)) {
400
- continue; // Indexed field's value is unchanged.
401
- }
545
+ this.stats.cacheHits++;
546
+ return _.find(collection, predicate);
547
+ }
402
548
 
403
- // 1. Remove the old value if it was indexed and pointed to this item.
404
- if (oldVal !== undefined && indexMap.get(oldVal) === key) {
405
- indexMap.delete(oldVal);
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.`);
406
553
  }
407
554
 
408
- // 2. Add the new value if it's defined.
409
- if (newVal !== undefined) {
410
- // Check for unique constraint violation before adding.
411
- if (indexDef.unique && indexMap.has(newVal)) {
412
- throw new IndexViolationError(
413
- `Unique index '${indexDef.name}' violated for value '${newVal}'.`
414
- );
415
- }
416
- indexMap.set(newVal, key);
417
- }
418
- }
419
- }
420
- }
555
+ this.stats.cacheHits++;
556
+ const indexMap = this._indices.get(indexName);
557
+ const objectKey = indexMap.get(value);
421
558
 
422
- // --- Public API ---
559
+ if (objectKey === undefined) return undefined;
423
560
 
424
- async get(path, defaultValue) {
425
- await this._ensureInitialized();
426
- this.stats.cacheHits++;
427
- if (path === undefined || path === null) {
428
- return this.cache;
561
+ const indexDef = this.config.indices.find((i) => i.name === indexName);
562
+ const fullPath = [..._.toPath(indexDef.path), objectKey];
563
+ return _.get(this.cache, fullPath);
429
564
  }
430
- return _.get(this.cache, path, defaultValue);
431
- }
432
-
433
- async has(path) {
434
- await this._ensureInitialized();
435
- this.stats.cacheHits++;
436
- return _.has(this.cache, path);
437
- }
438
-
439
- async set(path, value) {
440
- return this._atomicWrite((data) => {
441
- _.set(data, path, value);
442
- return data;
443
- });
444
- }
445
-
446
- async delete(path) {
447
- let deleted = false;
448
- await this._atomicWrite((data) => {
449
- deleted = _.unset(data, path);
450
- return data;
451
- });
452
- return deleted;
453
- }
454
-
455
- async push(path, ...items) {
456
- if (items.length === 0) return;
457
- return this._atomicWrite((data) => {
458
- const arr = _.get(data, path);
459
- const targetArray = Array.isArray(arr) ? arr : [];
460
- items.forEach((item) => {
461
- if (!targetArray.some((existing) => _.isEqual(existing, item))) {
462
- targetArray.push(item);
463
- }
464
- });
465
- _.set(data, path, targetArray);
466
- return data;
467
- });
468
- }
469
-
470
- async pull(path, ...itemsToRemove) {
471
- if (itemsToRemove.length === 0) return;
472
- return this._atomicWrite((data) => {
473
- const arr = _.get(data, path);
474
- if (Array.isArray(arr)) {
475
- _.pullAllWith(arr, itemsToRemove, _.isEqual);
476
- }
477
- return data;
478
- });
479
- }
480
-
481
- async transaction(transactionFn) {
482
- return this._atomicWrite(transactionFn);
483
- }
484
-
485
- async batch(ops, options = { stopOnError: false }) {
486
- if (!Array.isArray(ops) || ops.length === 0) return;
487
-
488
- return this._atomicWrite((data) => {
489
- for (const [index, op] of ops.entries()) {
490
- try {
491
- if (!op || !op.type || op.path === undefined)
492
- throw new Error("Invalid operation format: missing type or path.");
493
-
494
- switch (op.type) {
495
- case "set":
496
- if (!op.hasOwnProperty("value"))
497
- throw new Error("Set operation missing 'value'.");
498
- _.set(data, op.path, op.value);
499
- break;
500
- case "delete":
501
- _.unset(data, op.path);
502
- break;
503
- case "push":
504
- if (!Array.isArray(op.values))
505
- throw new Error("Push operation 'values' must be an array.");
506
- const arr = _.get(data, op.path);
507
- const targetArray = Array.isArray(arr) ? arr : [];
508
- op.values.forEach((item) => {
509
- if (!targetArray.some((existing) => _.isEqual(existing, item)))
510
- targetArray.push(item);
511
- });
512
- _.set(data, op.path, targetArray);
513
- break;
514
- case "pull":
515
- if (!Array.isArray(op.values))
516
- throw new Error("Pull operation 'values' must be an array.");
517
- const pullArr = _.get(data, op.path);
518
- if (Array.isArray(pullArr))
519
- _.pullAllWith(pullArr, op.values, _.isEqual);
520
- break;
521
- default:
522
- throw new Error(`Unsupported operation type: '${op.type}'.`);
523
- }
524
- } catch (err) {
525
- const errorMessage = `[JSONDatabase] Batch failed at operation index ${index} (type: ${op?.type}): ${err.message}`;
526
- if (options.stopOnError) {
527
- throw new Error(errorMessage);
528
- } else {
529
- console.error(errorMessage);
530
- }
531
- }
532
- }
533
- return data;
534
- });
535
- }
536
-
537
- async find(collectionPath, predicate) {
538
- await this._ensureInitialized();
539
- const collection = _.get(this.cache, collectionPath);
540
- if (typeof collection !== "object" || collection === null) return undefined;
541
-
542
- this.stats.cacheHits++;
543
- return _.find(collection, predicate);
544
- }
545
-
546
- async findByIndex(indexName, value) {
547
- await this._ensureInitialized();
548
- if (!this._indices.has(indexName)) {
549
- throw new Error(`Index with name '${indexName}' does not exist.`);
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 };
550
575
  }
551
576
 
552
- this.stats.cacheHits++;
553
- const indexMap = this._indices.get(indexName);
554
- const objectKey = indexMap.get(value);
555
-
556
- if (objectKey === undefined) return undefined;
557
-
558
- const indexDef = this.config.indices.find((i) => i.name === indexName);
559
- const fullPath = [..._.toPath(indexDef.path), objectKey];
560
- return _.get(this.cache, fullPath);
561
- }
562
-
563
- async clear() {
564
- console.warn(
565
- `[JSONDatabase] Clearing all data from ${this.filename}. This action is irreversible.`
566
- );
567
- return this._atomicWrite(() => ({}));
568
- }
569
-
570
- getStats() {
571
- return { ...this.stats };
572
- }
573
-
574
- async close() {
575
- await this.writeLock;
576
-
577
- this.cache = null;
578
- this._indices.clear();
579
- this.removeAllListeners();
580
- this._initPromise = null;
581
-
582
- const finalStats = JSON.stringify(this.getStats());
583
- console.log(
584
- `[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`
585
- );
586
- }
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
+ }
587
590
  }
588
591
 
589
- module.exports = JSONDatabase;
592
+ module.exports = JSONDatabase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-database-st",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "A simple, promise-based JSON file database for Node.js with atomic operations and lodash integration.",
5
5
  "main": "JSONDatabase.js",
6
6
  "scripts": {