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