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.
- package/JSONDatabase.js +478 -476
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
await this.
|
|
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
|
-
|
|
291
|
-
|
|
351
|
+
return this.writeLock;
|
|
352
|
+
}
|
|
292
353
|
|
|
293
|
-
|
|
354
|
+
// --- Indexing ---
|
|
294
355
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
425
|
+
// --- Public API ---
|
|
312
426
|
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
436
|
+
async has(path) {
|
|
437
|
+
await this._ensureInitialized();
|
|
438
|
+
this.stats.cacheHits++;
|
|
439
|
+
return _.has(this.cache, path);
|
|
440
|
+
}
|
|
320
441
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
545
|
+
this.stats.cacheHits++;
|
|
546
|
+
return _.find(collection, predicate);
|
|
547
|
+
}
|
|
403
548
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
559
|
+
if (objectKey === undefined) return undefined;
|
|
424
560
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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.
|
|
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"
|