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