json-database-st 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/JSONDatabase.js +257 -160
- package/package.json +1 -1
package/JSONDatabase.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// File: JSONDatabase.js
|
|
2
2
|
// Final, Complete, and Secure Version (Patched)
|
|
3
3
|
|
|
4
|
-
const fs = require(
|
|
5
|
-
const path = require(
|
|
6
|
-
const crypto = require(
|
|
7
|
-
const _ = require(
|
|
8
|
-
const EventEmitter = require(
|
|
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
|
+
// --- FIX: Add dependency for cross-process file locking. Run `npm install proper-lockfile`.
|
|
10
|
+
const lockfile = require("proper-lockfile");
|
|
9
11
|
|
|
10
12
|
// --- Custom Error Classes for Better Error Handling ---
|
|
11
13
|
|
|
@@ -32,7 +34,6 @@ class IndexViolationError extends DBError {}
|
|
|
32
34
|
/** Error for security-related issues like path traversal or bad keys. */
|
|
33
35
|
class SecurityError extends DBError {}
|
|
34
36
|
|
|
35
|
-
|
|
36
37
|
// --- Type Definitions for Clarity ---
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -74,13 +75,11 @@ class SecurityError extends DBError {}
|
|
|
74
75
|
* @property {boolean} [unique=false] - If true, enforces that the indexed field must be unique across the collection.
|
|
75
76
|
*/
|
|
76
77
|
|
|
77
|
-
|
|
78
78
|
// --- Cryptography Constants ---
|
|
79
|
-
const ALGORITHM =
|
|
79
|
+
const ALGORITHM = "aes-256-gcm";
|
|
80
80
|
const IV_LENGTH = 16;
|
|
81
81
|
const AUTH_TAG_LENGTH = 16;
|
|
82
82
|
|
|
83
|
-
|
|
84
83
|
/**
|
|
85
84
|
* A robust, secure, promise-based JSON file database with atomic operations, indexing, schema validation, and events.
|
|
86
85
|
* Includes encryption-at-rest and path traversal protection.
|
|
@@ -109,13 +108,23 @@ class JSONDatabase extends EventEmitter {
|
|
|
109
108
|
const resolvedPath = path.resolve(filename);
|
|
110
109
|
const workingDir = process.cwd();
|
|
111
110
|
if (!resolvedPath.startsWith(workingDir)) {
|
|
112
|
-
throw new SecurityError(
|
|
111
|
+
throw new SecurityError(
|
|
112
|
+
`Path traversal detected. Database path must be within the project directory: ${workingDir}`
|
|
113
|
+
);
|
|
113
114
|
}
|
|
114
|
-
this.filename = /\.json$/.test(resolvedPath)
|
|
115
|
+
this.filename = /\.json$/.test(resolvedPath)
|
|
116
|
+
? resolvedPath
|
|
117
|
+
: `${resolvedPath}.json`;
|
|
115
118
|
|
|
116
119
|
// --- Security Check: Encryption Key ---
|
|
117
|
-
if (
|
|
118
|
-
|
|
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
|
+
);
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
this.config = {
|
|
@@ -123,7 +132,9 @@ class JSONDatabase extends EventEmitter {
|
|
|
123
132
|
writeOnChange: options.writeOnChange !== false,
|
|
124
133
|
schema: options.schema || null,
|
|
125
134
|
indices: options.indices || [],
|
|
126
|
-
encryptionKey: options.encryptionKey
|
|
135
|
+
encryptionKey: options.encryptionKey
|
|
136
|
+
? Buffer.from(options.encryptionKey, "hex")
|
|
137
|
+
: null,
|
|
127
138
|
};
|
|
128
139
|
|
|
129
140
|
this.cache = null;
|
|
@@ -138,29 +149,44 @@ class JSONDatabase extends EventEmitter {
|
|
|
138
149
|
// --- Encryption & Decryption ---
|
|
139
150
|
_encrypt(data) {
|
|
140
151
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
141
|
-
const cipher = crypto.createCipheriv(
|
|
152
|
+
const cipher = crypto.createCipheriv(
|
|
153
|
+
ALGORITHM,
|
|
154
|
+
this.config.encryptionKey,
|
|
155
|
+
iv
|
|
156
|
+
);
|
|
142
157
|
const jsonString = JSON.stringify(data);
|
|
143
|
-
const encrypted = Buffer.concat([
|
|
158
|
+
const encrypted = Buffer.concat([
|
|
159
|
+
cipher.update(jsonString, "utf8"),
|
|
160
|
+
cipher.final(),
|
|
161
|
+
]);
|
|
144
162
|
const authTag = cipher.getAuthTag();
|
|
145
163
|
return JSON.stringify({
|
|
146
|
-
iv: iv.toString(
|
|
147
|
-
tag: authTag.toString(
|
|
148
|
-
content: encrypted.toString(
|
|
164
|
+
iv: iv.toString("hex"),
|
|
165
|
+
tag: authTag.toString("hex"),
|
|
166
|
+
content: encrypted.toString("hex"),
|
|
149
167
|
});
|
|
150
168
|
}
|
|
151
169
|
|
|
152
170
|
_decrypt(encryptedPayload) {
|
|
153
171
|
try {
|
|
154
172
|
const payload = JSON.parse(encryptedPayload);
|
|
155
|
-
const iv = Buffer.from(payload.iv,
|
|
156
|
-
const authTag = Buffer.from(payload.tag,
|
|
157
|
-
const encryptedContent = Buffer.from(payload.content,
|
|
158
|
-
const decipher = crypto.createDecipheriv(
|
|
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
|
+
);
|
|
159
181
|
decipher.setAuthTag(authTag);
|
|
160
|
-
const decrypted =
|
|
182
|
+
const decrypted =
|
|
183
|
+
decipher.update(encryptedContent, "hex", "utf8") +
|
|
184
|
+
decipher.final("utf8");
|
|
161
185
|
return JSON.parse(decrypted);
|
|
162
186
|
} catch (e) {
|
|
163
|
-
throw new SecurityError(
|
|
187
|
+
throw new SecurityError(
|
|
188
|
+
"Decryption failed. The file may be corrupted, tampered with, or the encryption key is incorrect."
|
|
189
|
+
);
|
|
164
190
|
}
|
|
165
191
|
}
|
|
166
192
|
|
|
@@ -168,13 +194,35 @@ class JSONDatabase extends EventEmitter {
|
|
|
168
194
|
|
|
169
195
|
/** @private Kicks off the initialization process. */
|
|
170
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.
|
|
212
|
+
}
|
|
213
|
+
|
|
171
214
|
try {
|
|
172
215
|
await this._refreshCache();
|
|
173
216
|
this._rebuildAllIndices();
|
|
174
217
|
} catch (err) {
|
|
175
|
-
const initError = new DBInitializationError(
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
);
|
|
178
226
|
// --- ENHANCEMENT: Make the instance unusable if init fails ---
|
|
179
227
|
// By re-throwing here, the _initPromise will be rejected, and all subsequent
|
|
180
228
|
// operations waiting on _ensureInitialized() will fail immediately.
|
|
@@ -185,22 +233,25 @@ class JSONDatabase extends EventEmitter {
|
|
|
185
233
|
/** @private Reads file, decrypts if necessary, and populates cache. */
|
|
186
234
|
async _refreshCache() {
|
|
187
235
|
try {
|
|
188
|
-
const fileContent = await fs.readFile(this.filename,
|
|
236
|
+
const fileContent = await fs.readFile(this.filename, "utf8");
|
|
189
237
|
if (this.config.encryptionKey) {
|
|
190
|
-
this.cache =
|
|
238
|
+
this.cache =
|
|
239
|
+
fileContent.trim() === "" ? {} : this._decrypt(fileContent);
|
|
191
240
|
} else {
|
|
192
|
-
this.cache = fileContent.trim() ===
|
|
241
|
+
this.cache = fileContent.trim() === "" ? {} : JSON.parse(fileContent);
|
|
193
242
|
}
|
|
194
243
|
this.stats.reads++;
|
|
195
244
|
} catch (err) {
|
|
196
|
-
if (err.code ===
|
|
197
|
-
console.warn(
|
|
245
|
+
if (err.code === "ENOENT") {
|
|
246
|
+
console.warn(
|
|
247
|
+
`[JSONDatabase] File ${this.filename} not found. Creating.`
|
|
248
|
+
);
|
|
198
249
|
this.cache = {};
|
|
199
|
-
|
|
200
|
-
await fs.writeFile(this.filename, initialContent, 'utf8');
|
|
201
|
-
this.stats.writes++;
|
|
250
|
+
// Do not write file here; _atomicWrite will create it safely.
|
|
202
251
|
} else if (err instanceof SyntaxError && !this.config.encryptionKey) {
|
|
203
|
-
throw new DBInitializationError(
|
|
252
|
+
throw new DBInitializationError(
|
|
253
|
+
`Failed to parse JSON from ${this.filename}. File is corrupted.`
|
|
254
|
+
);
|
|
204
255
|
} else {
|
|
205
256
|
throw err; // Re-throw security, crypto, and other errors
|
|
206
257
|
}
|
|
@@ -209,41 +260,56 @@ class JSONDatabase extends EventEmitter {
|
|
|
209
260
|
|
|
210
261
|
/** @private Ensures all operations wait for initialization to complete. */
|
|
211
262
|
async _ensureInitialized() {
|
|
212
|
-
|
|
213
|
-
|
|
263
|
+
// This promise will be rejected if _initialize() fails, stopping all operations.
|
|
264
|
+
return this._initPromise;
|
|
214
265
|
}
|
|
215
266
|
|
|
216
267
|
/** @private Performs an atomic write operation. */
|
|
217
268
|
async _atomicWrite(operationFn) {
|
|
218
269
|
await this._ensureInitialized();
|
|
219
270
|
|
|
220
|
-
// This promise chain ensures all writes happen one after another.
|
|
271
|
+
// This promise chain ensures all writes *from this process* happen one after another.
|
|
221
272
|
this.writeLock = this.writeLock.then(async () => {
|
|
222
|
-
|
|
223
|
-
const oldData = this.cache;
|
|
224
|
-
const dataToModify = _.cloneDeep(oldData);
|
|
225
|
-
|
|
273
|
+
let releaseLock;
|
|
226
274
|
try {
|
|
227
|
-
// ---
|
|
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
|
+
|
|
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();
|
|
289
|
+
|
|
290
|
+
const oldData = this.cache;
|
|
291
|
+
const dataToModify = _.cloneDeep(oldData);
|
|
292
|
+
|
|
228
293
|
const newData = await operationFn(dataToModify);
|
|
229
294
|
|
|
230
|
-
// --- ENHANCEMENT: Stricter check to prevent accidental data loss ---
|
|
231
295
|
if (newData === undefined) {
|
|
232
|
-
throw new TransactionError(
|
|
296
|
+
throw new TransactionError(
|
|
297
|
+
"Atomic operation function returned undefined. Aborting to prevent data loss. Did you forget to `return data`?"
|
|
298
|
+
);
|
|
233
299
|
}
|
|
234
300
|
|
|
235
301
|
if (this.config.schema) {
|
|
236
302
|
const validationResult = this.config.schema.safeParse(newData);
|
|
237
303
|
if (!validationResult.success) {
|
|
238
|
-
throw new ValidationError(
|
|
304
|
+
throw new ValidationError(
|
|
305
|
+
"Schema validation failed.",
|
|
306
|
+
validationResult.error.issues
|
|
307
|
+
);
|
|
239
308
|
}
|
|
240
309
|
}
|
|
241
|
-
|
|
242
|
-
// --- ENHANCEMENT: Update indices *before* the write to catch violations early ---
|
|
243
|
-
// This will throw an IndexViolationError if there's a problem.
|
|
310
|
+
|
|
244
311
|
this._updateIndices(oldData, newData);
|
|
245
312
|
|
|
246
|
-
// Only write to disk if data has actually changed.
|
|
247
313
|
if (this.config.writeOnChange && _.isEqual(newData, oldData)) {
|
|
248
314
|
return oldData; // Return the unchanged data
|
|
249
315
|
}
|
|
@@ -251,89 +317,114 @@ class JSONDatabase extends EventEmitter {
|
|
|
251
317
|
const contentToWrite = this.config.encryptionKey
|
|
252
318
|
? this._encrypt(newData)
|
|
253
319
|
: JSON.stringify(newData, null, this.config.prettyPrint ? 2 : 0);
|
|
254
|
-
|
|
255
|
-
await fs.writeFile(this.filename, contentToWrite, 'utf8');
|
|
256
320
|
|
|
257
|
-
//
|
|
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
|
+
|
|
258
327
|
this.cache = newData;
|
|
259
328
|
this.stats.writes++;
|
|
260
|
-
|
|
261
|
-
this.emit('write', { filename: this.filename, timestamp: Date.now() });
|
|
262
|
-
this.emit('change', { oldValue: oldData, newValue: newData });
|
|
263
329
|
|
|
264
|
-
|
|
330
|
+
this.emit("write", { filename: this.filename, timestamp: Date.now() });
|
|
331
|
+
this.emit("change", { oldValue: oldData, newValue: newData });
|
|
265
332
|
|
|
333
|
+
return newData;
|
|
266
334
|
} catch (error) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
346
|
}
|
|
273
347
|
});
|
|
274
348
|
|
|
275
349
|
return this.writeLock;
|
|
276
350
|
}
|
|
277
|
-
|
|
351
|
+
|
|
278
352
|
// --- Indexing ---
|
|
279
353
|
|
|
280
354
|
/** @private Clears and rebuilds all defined indices from the current cache. */
|
|
281
355
|
_rebuildAllIndices() {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
356
|
+
this._indices.clear();
|
|
357
|
+
for (const indexDef of this.config.indices) {
|
|
358
|
+
this._indices.set(indexDef.name, new Map());
|
|
359
|
+
}
|
|
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);
|
|
363
|
+
}
|
|
364
|
+
console.log(
|
|
365
|
+
`[JSONDatabase] Rebuilt ${this.config.indices.length} indices for ${this.filename}.`
|
|
366
|
+
);
|
|
290
367
|
}
|
|
291
368
|
|
|
292
|
-
/**
|
|
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
|
+
*/
|
|
293
375
|
_updateIndices(oldData, newData) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
|
|
397
|
+
const oldVal = oldItem?.[indexDef.field];
|
|
398
|
+
const newVal = newItem?.[indexDef.field];
|
|
399
|
+
|
|
400
|
+
if (_.isEqual(oldVal, newVal)) {
|
|
401
|
+
continue; // Indexed field's value is unchanged.
|
|
402
|
+
}
|
|
403
|
+
|
|
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);
|
|
407
|
+
}
|
|
408
|
+
|
|
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
|
+
);
|
|
326
416
|
}
|
|
417
|
+
indexMap.set(newVal, key);
|
|
418
|
+
}
|
|
327
419
|
}
|
|
420
|
+
}
|
|
328
421
|
}
|
|
329
422
|
|
|
330
|
-
|
|
331
423
|
// --- Public API ---
|
|
332
424
|
|
|
333
425
|
async get(path, defaultValue) {
|
|
334
426
|
await this._ensureInitialized();
|
|
335
427
|
this.stats.cacheHits++;
|
|
336
|
-
// --- CRITICAL FIX: Handle undefined/null path to get the entire object ---
|
|
337
428
|
if (path === undefined || path === null) {
|
|
338
429
|
return this.cache;
|
|
339
430
|
}
|
|
@@ -347,7 +438,7 @@ class JSONDatabase extends EventEmitter {
|
|
|
347
438
|
}
|
|
348
439
|
|
|
349
440
|
async set(path, value) {
|
|
350
|
-
return this._atomicWrite(data => {
|
|
441
|
+
return this._atomicWrite((data) => {
|
|
351
442
|
_.set(data, path, value);
|
|
352
443
|
return data;
|
|
353
444
|
});
|
|
@@ -355,7 +446,7 @@ class JSONDatabase extends EventEmitter {
|
|
|
355
446
|
|
|
356
447
|
async delete(path) {
|
|
357
448
|
let deleted = false;
|
|
358
|
-
await this._atomicWrite(data => {
|
|
449
|
+
await this._atomicWrite((data) => {
|
|
359
450
|
deleted = _.unset(data, path);
|
|
360
451
|
return data;
|
|
361
452
|
});
|
|
@@ -364,12 +455,11 @@ class JSONDatabase extends EventEmitter {
|
|
|
364
455
|
|
|
365
456
|
async push(path, ...items) {
|
|
366
457
|
if (items.length === 0) return;
|
|
367
|
-
return this._atomicWrite(data => {
|
|
458
|
+
return this._atomicWrite((data) => {
|
|
368
459
|
const arr = _.get(data, path);
|
|
369
460
|
const targetArray = Array.isArray(arr) ? arr : [];
|
|
370
|
-
items.forEach(item => {
|
|
371
|
-
|
|
372
|
-
if (!targetArray.some(existing => _.isEqual(existing, item))) {
|
|
461
|
+
items.forEach((item) => {
|
|
462
|
+
if (!targetArray.some((existing) => _.isEqual(existing, item))) {
|
|
373
463
|
targetArray.push(item);
|
|
374
464
|
}
|
|
375
465
|
});
|
|
@@ -380,7 +470,7 @@ class JSONDatabase extends EventEmitter {
|
|
|
380
470
|
|
|
381
471
|
async pull(path, ...itemsToRemove) {
|
|
382
472
|
if (itemsToRemove.length === 0) return;
|
|
383
|
-
return this._atomicWrite(data => {
|
|
473
|
+
return this._atomicWrite((data) => {
|
|
384
474
|
const arr = _.get(data, path);
|
|
385
475
|
if (Array.isArray(arr)) {
|
|
386
476
|
_.pullAllWith(arr, itemsToRemove, _.isEqual);
|
|
@@ -396,43 +486,49 @@ class JSONDatabase extends EventEmitter {
|
|
|
396
486
|
async batch(ops, options = { stopOnError: false }) {
|
|
397
487
|
if (!Array.isArray(ops) || ops.length === 0) return;
|
|
398
488
|
|
|
399
|
-
return this._atomicWrite(data => {
|
|
489
|
+
return this._atomicWrite((data) => {
|
|
400
490
|
for (const [index, op] of ops.entries()) {
|
|
401
491
|
try {
|
|
402
|
-
if (!op || !op.type || op.path === undefined)
|
|
403
|
-
|
|
492
|
+
if (!op || !op.type || op.path === undefined)
|
|
493
|
+
throw new Error("Invalid operation format: missing type or path.");
|
|
494
|
+
|
|
404
495
|
switch (op.type) {
|
|
405
|
-
case
|
|
406
|
-
if (!op.hasOwnProperty(
|
|
496
|
+
case "set":
|
|
497
|
+
if (!op.hasOwnProperty("value"))
|
|
498
|
+
throw new Error("Set operation missing 'value'.");
|
|
407
499
|
_.set(data, op.path, op.value);
|
|
408
500
|
break;
|
|
409
|
-
case
|
|
501
|
+
case "delete":
|
|
410
502
|
_.unset(data, op.path);
|
|
411
503
|
break;
|
|
412
|
-
case
|
|
413
|
-
if (!Array.isArray(op.values))
|
|
504
|
+
case "push":
|
|
505
|
+
if (!Array.isArray(op.values))
|
|
506
|
+
throw new Error("Push operation 'values' must be an array.");
|
|
414
507
|
const arr = _.get(data, op.path);
|
|
415
508
|
const targetArray = Array.isArray(arr) ? arr : [];
|
|
416
|
-
op.values.forEach(item => {
|
|
417
|
-
|
|
509
|
+
op.values.forEach((item) => {
|
|
510
|
+
if (!targetArray.some((existing) => _.isEqual(existing, item)))
|
|
511
|
+
targetArray.push(item);
|
|
418
512
|
});
|
|
419
513
|
_.set(data, op.path, targetArray);
|
|
420
514
|
break;
|
|
421
|
-
case
|
|
422
|
-
if (!Array.isArray(op.values))
|
|
515
|
+
case "pull":
|
|
516
|
+
if (!Array.isArray(op.values))
|
|
517
|
+
throw new Error("Pull operation 'values' must be an array.");
|
|
423
518
|
const pullArr = _.get(data, op.path);
|
|
424
|
-
if (Array.isArray(pullArr))
|
|
519
|
+
if (Array.isArray(pullArr))
|
|
520
|
+
_.pullAllWith(pullArr, op.values, _.isEqual);
|
|
425
521
|
break;
|
|
426
522
|
default:
|
|
427
523
|
throw new Error(`Unsupported operation type: '${op.type}'.`);
|
|
428
524
|
}
|
|
429
525
|
} catch (err) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
+
}
|
|
436
532
|
}
|
|
437
533
|
}
|
|
438
534
|
return data;
|
|
@@ -440,35 +536,35 @@ class JSONDatabase extends EventEmitter {
|
|
|
440
536
|
}
|
|
441
537
|
|
|
442
538
|
async find(collectionPath, predicate) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return _.find(collection, 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);
|
|
450
545
|
}
|
|
451
|
-
|
|
546
|
+
|
|
452
547
|
async findByIndex(indexName, value) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
548
|
+
await this._ensureInitialized();
|
|
549
|
+
if (!this._indices.has(indexName)) {
|
|
550
|
+
throw new Error(`Index with name '${indexName}' does not exist.`);
|
|
551
|
+
}
|
|
457
552
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
553
|
+
this.stats.cacheHits++;
|
|
554
|
+
const indexMap = this._indices.get(indexName);
|
|
555
|
+
const objectKey = indexMap.get(value);
|
|
461
556
|
|
|
462
|
-
|
|
557
|
+
if (objectKey === undefined) return undefined;
|
|
463
558
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return _.get(this.cache, fullPath);
|
|
559
|
+
const indexDef = this.config.indices.find((i) => i.name === indexName);
|
|
560
|
+
const fullPath = [..._.toPath(indexDef.path), objectKey];
|
|
561
|
+
return _.get(this.cache, fullPath);
|
|
468
562
|
}
|
|
469
563
|
|
|
470
564
|
async clear() {
|
|
471
|
-
console.warn(
|
|
565
|
+
console.warn(
|
|
566
|
+
`[JSONDatabase] Clearing all data from ${this.filename}. This action is irreversible.`
|
|
567
|
+
);
|
|
472
568
|
return this._atomicWrite(() => ({}));
|
|
473
569
|
}
|
|
474
570
|
|
|
@@ -477,17 +573,18 @@ class JSONDatabase extends EventEmitter {
|
|
|
477
573
|
}
|
|
478
574
|
|
|
479
575
|
async close() {
|
|
480
|
-
// Wait for the last pending write operation to finish
|
|
481
576
|
await this.writeLock;
|
|
482
|
-
|
|
577
|
+
|
|
483
578
|
this.cache = null;
|
|
484
579
|
this._indices.clear();
|
|
485
580
|
this.removeAllListeners();
|
|
486
|
-
this._initPromise = null;
|
|
581
|
+
this._initPromise = null;
|
|
487
582
|
|
|
488
583
|
const finalStats = JSON.stringify(this.getStats());
|
|
489
|
-
console.log(
|
|
584
|
+
console.log(
|
|
585
|
+
`[JSONDatabase] Closed connection to ${this.filename}. Final Stats: ${finalStats}`
|
|
586
|
+
);
|
|
490
587
|
}
|
|
491
588
|
}
|
|
492
589
|
|
|
493
|
-
module.exports = JSONDatabase;
|
|
590
|
+
module.exports = JSONDatabase;
|
package/package.json
CHANGED