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