json-database-st 2.0.2 → 3.1.4

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.
@@ -0,0 +1,567 @@
1
+ const fs = require("fs").promises;
2
+ const fsSync = require("fs");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+ const _ = require("lodash");
6
+ const EventEmitter = require("events");
7
+ const lockfile = require("proper-lockfile");
8
+
9
+ // --- Custom Errors (Required for Tests) ---
10
+ class DBError extends Error {
11
+ constructor(msg) {
12
+ super(msg);
13
+ this.name = this.constructor.name;
14
+ }
15
+ }
16
+ class TransactionError extends DBError {}
17
+ class ValidationError extends DBError {
18
+ constructor(msg, issues) {
19
+ super(msg);
20
+ this.issues = issues;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * JSONDatabase
26
+ * Restored full compatibility with Jest tests while keeping performance upgrades.
27
+ */
28
+ class JSONDatabase extends EventEmitter {
29
+ constructor(filename, options = {}) {
30
+ super();
31
+
32
+ // 1. Security Checks
33
+ const resolvedPath = path.resolve(filename);
34
+ if (!resolvedPath.startsWith(process.cwd())) {
35
+ throw new Error(
36
+ "Security Violation: Database path must be inside the project directory."
37
+ );
38
+ }
39
+ this.filename = resolvedPath.endsWith(".json")
40
+ ? resolvedPath
41
+ : `${resolvedPath}.json`;
42
+
43
+ // 2. Configuration
44
+ this.config = {
45
+ encryptionKey: options.encryptionKey
46
+ ? Buffer.from(options.encryptionKey, "hex")
47
+ : null,
48
+ prettyPrint: options.prettyPrint !== false,
49
+ saveDelay: options.saveDelay || 60, // Debounce ms
50
+ indices: options.indices || [],
51
+ schema: options.schema || null,
52
+ };
53
+
54
+ if (this.config.encryptionKey && this.config.encryptionKey.length !== 32) {
55
+ throw new Error("Encryption key must be exactly 32 bytes.");
56
+ }
57
+
58
+ // 3. Internal State
59
+ this.data = {};
60
+ this._indices = new Map();
61
+ this._loaded = false;
62
+
63
+ // 4. Write Queue System (The "Bus")
64
+ this._writeQueue = [];
65
+ this._writeScheduled = false;
66
+ this._writeLockPromise = Promise.resolve();
67
+
68
+ // 5. Middleware
69
+ this._middleware = {
70
+ before: { set: [], delete: [], push: [], pull: [] },
71
+ after: { set: [], delete: [], push: [], pull: [] },
72
+ };
73
+
74
+ // 6. Initialize
75
+ this._initPromise = this._initialize();
76
+ }
77
+
78
+ // ==========================================
79
+ // INTERNAL CORE
80
+ // ==========================================
81
+
82
+ async _initialize() {
83
+ try {
84
+ // Crash Recovery
85
+ if (fsSync.existsSync(this.filename + ".tmp")) {
86
+ try {
87
+ await fs.rename(this.filename + ".tmp", this.filename);
88
+ } catch (e) {}
89
+ }
90
+
91
+ // Ensure file exists
92
+ try {
93
+ await fs.access(this.filename);
94
+ } catch (e) {
95
+ // Do not create file here. Wait for first write.
96
+ }
97
+
98
+ // Read
99
+ const content = await fs.readFile(this.filename, "utf8");
100
+ this.data = content.trim()
101
+ ? this.config.encryptionKey
102
+ ? this._decrypt(content)
103
+ : JSON.parse(content)
104
+ : {};
105
+ } catch (e) {
106
+ this.data = {}; // Fallback
107
+ }
108
+ this._rebuildIndices();
109
+ this._loaded = true;
110
+ this.emit("ready");
111
+ }
112
+
113
+ async _ensureInitialized() {
114
+ if (!this._loaded) await this._initPromise;
115
+ }
116
+
117
+ /**
118
+ * The Shared Write Engine.
119
+ * Batches 10,000 calls into 1 disk write.
120
+ */
121
+ async _save() {
122
+ // Update indices instantly in memory
123
+ // this._rebuildIndices(); // REMOVED: Incremental updates are now used
124
+ this.emit("change", this.data);
125
+
126
+ return new Promise((resolve, reject) => {
127
+ this._writeQueue.push({ resolve, reject });
128
+
129
+ if (this._writeScheduled) return;
130
+
131
+ this._writeScheduled = true;
132
+ setTimeout(async () => {
133
+ // Wait for any previous physical write to finish
134
+ await this._writeLockPromise;
135
+
136
+ // Start physical write
137
+ this._writeLockPromise = (async () => {
138
+ // Take a snapshot of everyone waiting and clear the queue
139
+ const subscribers = [...this._writeQueue];
140
+ this._writeQueue = [];
141
+ this._writeScheduled = false;
142
+
143
+ let release;
144
+ try {
145
+ // Ensure file exists before locking
146
+ try {
147
+ await fs.access(this.filename);
148
+ } catch (e) {
149
+ await fs.mkdir(path.dirname(this.filename), { recursive: true });
150
+ try {
151
+ await fs.writeFile(this.filename, "", { flag: "wx" });
152
+ } catch (err) {
153
+ if (err.code !== "EEXIST") throw err;
154
+ }
155
+ }
156
+
157
+ release = await lockfile.lock(this.filename, { retries: 3 });
158
+
159
+ const content = this.config.encryptionKey
160
+ ? this._encrypt(this.data)
161
+ : JSON.stringify(
162
+ this.data,
163
+ null,
164
+ this.config.prettyPrint ? 2 : 0
165
+ );
166
+
167
+ // Safe Write Pattern
168
+ const temp = this.filename + ".tmp";
169
+ await fs.mkdir(path.dirname(this.filename), { recursive: true });
170
+ await fs.writeFile(temp, content);
171
+ await fs.rename(temp, this.filename);
172
+
173
+ this.emit("write");
174
+ subscribers.forEach((s) => s.resolve(true));
175
+ } catch (e) {
176
+ console.error("[JSONDatabase] Save Failed:", e);
177
+ subscribers.forEach((s) => s.reject(e));
178
+ } finally {
179
+ if (release) await release();
180
+ }
181
+ })();
182
+ }, this.config.saveDelay);
183
+ });
184
+ }
185
+
186
+ // ==========================================
187
+ // PUBLIC API
188
+ // ==========================================
189
+
190
+ async set(path, value) {
191
+ await this._ensureInitialized();
192
+ const ctx = this._runMiddleware("before", "set", { path, value });
193
+
194
+ // Incremental Index Update
195
+ this._handleIndexUpdate(ctx.path, ctx.value, () => {
196
+ _.set(this.data, ctx.path, ctx.value);
197
+ });
198
+
199
+ if (this.config.schema) {
200
+ const result = this.config.schema.safeParse(this.data);
201
+ // Tests expect exactly "Schema validation failed" for one test case
202
+ if (!result.success)
203
+ throw new ValidationError(
204
+ "Schema validation failed",
205
+ result.error.issues
206
+ );
207
+ }
208
+
209
+ const p = this._save();
210
+ this._runMiddleware("after", "set", { ...ctx, finalData: this.data });
211
+ return p;
212
+ }
213
+
214
+ async get(path, defaultValue = null) {
215
+ await this._ensureInitialized();
216
+ if (path === null || path === undefined) return this.data; // Fix for test: "get() should return entire cache"
217
+ return _.get(this.data, path, defaultValue);
218
+ }
219
+
220
+ async has(path) {
221
+ await this._ensureInitialized();
222
+ return _.has(this.data, path);
223
+ }
224
+
225
+ async delete(path) {
226
+ await this._ensureInitialized();
227
+ const ctx = this._runMiddleware("before", "delete", { path });
228
+
229
+ // Incremental Index Update (Remove)
230
+ this._removeFromIndex(ctx.path);
231
+
232
+ const deleted = _.unset(this.data, ctx.path); // Fix: Tests might check this boolean
233
+ const p = this._save();
234
+ this._runMiddleware("after", "delete", { ...ctx, data: this.data });
235
+ return deleted; // Return boolean for tests
236
+ }
237
+
238
+ async push(path, ...items) {
239
+ await this._ensureInitialized();
240
+ const arr = _.get(this.data, path, []);
241
+ // Fix: Tests expect it to create array if missing
242
+ const targetArray = Array.isArray(arr) ? arr : [];
243
+
244
+ let modified = false;
245
+ items.forEach((item) => {
246
+ // Deep Unique Check
247
+ if (!targetArray.some((x) => _.isEqual(x, item))) {
248
+ targetArray.push(item);
249
+ modified = true;
250
+ }
251
+ });
252
+
253
+ if (modified || targetArray.length !== arr.length || !Array.isArray(arr)) {
254
+ _.set(this.data, path, targetArray);
255
+ // Rebuild indices if we touched a collection that is indexed
256
+ this._checkAndRebuildIndex(path);
257
+ return this._save();
258
+ }
259
+ }
260
+
261
+ async pull(path, ...items) {
262
+ await this._ensureInitialized();
263
+ const arr = _.get(this.data, path);
264
+ if (Array.isArray(arr)) {
265
+ _.pullAllWith(arr, items, _.isEqual);
266
+ this._checkAndRebuildIndex(path);
267
+ return this._save();
268
+ }
269
+ }
270
+
271
+ // --- Math Helpers ---
272
+ async add(path, amount) {
273
+ await this._ensureInitialized();
274
+ const current = _.get(this.data, path, 0);
275
+ if (typeof current !== "number")
276
+ throw new Error(`Value at ${path} is not a number`);
277
+ _.set(this.data, path, current + amount);
278
+ return this._save();
279
+ }
280
+
281
+ async subtract(path, amount) {
282
+ return this.add(path, -amount);
283
+ }
284
+
285
+ // --- Advanced ---
286
+
287
+ async transaction(fn) {
288
+ await this._ensureInitialized();
289
+ // Fix for tests: Transaction must return value
290
+ const backup = _.cloneDeep(this.data);
291
+ try {
292
+ const result = await fn(this.data);
293
+ if (result === undefined)
294
+ throw new TransactionError(
295
+ "Atomic operation function returned undefined"
296
+ );
297
+ this._rebuildIndices(); // Safety: Full rebuild after arbitrary transaction
298
+ await this._save();
299
+ return result;
300
+ } catch (e) {
301
+ this.data = backup;
302
+ throw e;
303
+ }
304
+ }
305
+
306
+ async batch(ops) {
307
+ await this._ensureInitialized();
308
+ for (const op of ops) {
309
+ if (op.type === "set") _.set(this.data, op.path, op.value);
310
+ else if (op.type === "delete") _.unset(this.data, op.path);
311
+ else if (op.type === "push") {
312
+ const arr = _.get(this.data, op.path, []);
313
+ const target = Array.isArray(arr) ? arr : [];
314
+ op.values.forEach((v) => {
315
+ if (!target.some((x) => _.isEqual(x, v))) target.push(v);
316
+ });
317
+ _.set(this.data, op.path, target);
318
+ }
319
+ }
320
+ this._rebuildIndices(); // Safety: Full rebuild after batch
321
+ return this._save();
322
+ }
323
+
324
+ async clear() {
325
+ await this._ensureInitialized();
326
+ this.data = {};
327
+ return this._save();
328
+ }
329
+
330
+ // --- Search ---
331
+
332
+ async find(path, predicate) {
333
+ await this._ensureInitialized();
334
+ return _.find(_.get(this.data, path), predicate);
335
+ }
336
+
337
+ async findByIndex(indexName, value) {
338
+ await this._ensureInitialized();
339
+ const map = this._indices.get(indexName);
340
+ // Fix: Tests check for index existence
341
+ if (!this.config.indices.find((i) => i.name === indexName))
342
+ throw new Error(`Index with name '${indexName}' does not exist.`);
343
+
344
+ const path = map.get(value);
345
+ return path ? _.get(this.data, path) : undefined;
346
+ }
347
+
348
+ async paginate(path, page = 1, limit = 10) {
349
+ await this._ensureInitialized();
350
+ const items = _.get(this.data, path, []);
351
+ if (!Array.isArray(items)) throw new Error("Target is not an array");
352
+
353
+ const total = items.length;
354
+ const totalPages = Math.ceil(total / limit);
355
+ const offset = (page - 1) * limit;
356
+
357
+ return {
358
+ data: items.slice(offset, offset + limit),
359
+ meta: { total, page, limit, totalPages, hasNext: page < totalPages },
360
+ };
361
+ }
362
+
363
+ // --- Utils ---
364
+
365
+ async createSnapshot(label = "backup") {
366
+ await this._ensureInitialized();
367
+ await this._writeLockPromise;
368
+ const backupName = `${this.filename.replace(
369
+ ".json",
370
+ ""
371
+ )}.${label}-${Date.now()}.bak`;
372
+ await fs.copyFile(this.filename, backupName);
373
+ return backupName;
374
+ }
375
+
376
+ async close() {
377
+ await this._writeLockPromise;
378
+ this.removeAllListeners();
379
+ this.data = null;
380
+ }
381
+
382
+ // --- Middleware ---
383
+ before(op, pattern, cb) {
384
+ this._addM("before", op, pattern, cb);
385
+ }
386
+ after(op, pattern, cb) {
387
+ this._addM("after", op, pattern, cb);
388
+ }
389
+ _addM(hook, op, pattern, cb) {
390
+ const regex = new RegExp(
391
+ `^${pattern.replace(/\./g, "\\.").replace(/\*/g, ".*")}$`
392
+ );
393
+ this._middleware[hook][op].push({ regex, cb });
394
+ }
395
+ _runMiddleware(hook, op, ctx) {
396
+ this._middleware[hook][op].forEach((m) => {
397
+ if (m.regex.test(ctx.path)) ctx = m.cb(ctx);
398
+ });
399
+ return ctx;
400
+ }
401
+
402
+ // --- Internals ---
403
+ _rebuildIndices() {
404
+ this._indices.clear();
405
+ this.config.indices.forEach((idx) => {
406
+ const map = new Map();
407
+ const col = _.get(this.data, idx.path);
408
+ if (typeof col === "object" && col !== null) {
409
+ _.forEach(col, (item, key) => {
410
+ const val = _.get(item, idx.field);
411
+ if (val !== undefined) {
412
+ // Fix: Unique constraint check for tests
413
+ if (idx.unique && map.has(val)) {
414
+ throw new Error(
415
+ `Unique index '${idx.name}' violated for value '${val}'`
416
+ );
417
+ }
418
+ map.set(val, `${idx.path}.${key}`);
419
+ }
420
+ });
421
+ }
422
+ this._indices.set(idx.name, map);
423
+ });
424
+ }
425
+
426
+ // Helper to handle the complexity of index updates
427
+ // We will call this AFTER modification, but we need to know what changed.
428
+
429
+ _rebuildSingleIndex(idx) {
430
+ const map = new Map();
431
+ const col = _.get(this.data, idx.path);
432
+ if (typeof col === "object" && col !== null) {
433
+ _.forEach(col, (item, key) => {
434
+ const val = _.get(item, idx.field);
435
+ if (val !== undefined) {
436
+ if (idx.unique && map.has(val)) {
437
+ // validation usually happens before, but here we just index
438
+ }
439
+ map.set(val, `${idx.path}.${key}`);
440
+ }
441
+ });
442
+ }
443
+ this._indices.set(idx.name, map);
444
+ }
445
+
446
+ _checkAndRebuildIndex(path) {
447
+ // If path touches any index, rebuild that index (Fallback for push/pull)
448
+ this.config.indices.forEach((idx) => {
449
+ if (path === idx.path || path.startsWith(idx.path + ".")) {
450
+ this._rebuildSingleIndex(idx);
451
+ }
452
+ });
453
+ }
454
+
455
+ // Optimized Index Update for SET
456
+ _handleIndexUpdate(path, value, performUpdate) {
457
+ // 1. Identify affected indices
458
+ const affected = [];
459
+ this.config.indices.forEach((idx) => {
460
+ if (path.startsWith(idx.path + ".")) {
461
+ const relative = path.slice(idx.path.length + 1);
462
+ const parts = relative.split(".");
463
+ const key = parts[0];
464
+ affected.push({ idx, key, itemPath: `${idx.path}.${key}` });
465
+ } else if (path === idx.path) {
466
+ affected.push({ idx, rebuild: true });
467
+ }
468
+ });
469
+
470
+ // 2. Capture Old Values
471
+ const oldValues = affected.map((a) => {
472
+ if (a.rebuild) return null;
473
+ const item = _.get(this.data, a.itemPath);
474
+ return item ? _.get(item, a.idx.field) : undefined;
475
+ });
476
+
477
+ // 3. Perform Update
478
+ performUpdate();
479
+
480
+ // 4. Update Indices
481
+ affected.forEach((a, i) => {
482
+ if (a.rebuild) {
483
+ this._rebuildSingleIndex(a.idx);
484
+ return;
485
+ }
486
+
487
+ const map = this._indices.get(a.idx.name);
488
+ const oldVal = oldValues[i];
489
+
490
+ // Remove Old
491
+ if (oldVal !== undefined && map.get(oldVal) === a.itemPath) {
492
+ map.delete(oldVal);
493
+ }
494
+
495
+ // Add New
496
+ const newItem = _.get(this.data, a.itemPath);
497
+ const newVal = _.get(newItem, a.idx.field);
498
+
499
+ if (newVal !== undefined) {
500
+ if (a.idx.unique && map.has(newVal) && map.get(newVal) !== a.itemPath) {
501
+ throw new Error(
502
+ `Unique index '${a.idx.name}' violated for value '${newVal}'`
503
+ );
504
+ }
505
+ map.set(newVal, a.itemPath);
506
+ }
507
+ });
508
+ }
509
+
510
+ _removeFromIndex(path) {
511
+ this.config.indices.forEach((idx) => {
512
+ if (path.startsWith(idx.path + ".")) {
513
+ const relative = path.slice(idx.path.length + 1);
514
+ const parts = relative.split(".");
515
+ const key = parts[0];
516
+ const itemPath = `${idx.path}.${key}`;
517
+
518
+ // If we are deleting the item or parent of item
519
+ if (path === itemPath || path === idx.path) {
520
+ // If we delete the whole collection or item, we need to remove from index.
521
+ // Easiest is to just rebuild or remove specific entries.
522
+ // If deleting item:
523
+ if (path === itemPath) {
524
+ const item = _.get(this.data, itemPath);
525
+ const val = _.get(item, idx.field);
526
+ const map = this._indices.get(idx.name);
527
+ if (val !== undefined && map) map.delete(val);
528
+ } else {
529
+ this._rebuildSingleIndex(idx);
530
+ }
531
+ }
532
+ }
533
+ });
534
+ }
535
+
536
+ _encrypt(d) {
537
+ const iv = crypto.randomBytes(16);
538
+ const c = crypto.createCipheriv(
539
+ "aes-256-gcm",
540
+ this.config.encryptionKey,
541
+ iv
542
+ );
543
+ const e = Buffer.concat([c.update(JSON.stringify(d)), c.final()]);
544
+ return JSON.stringify({
545
+ iv: iv.toString("hex"),
546
+ tag: c.getAuthTag().toString("hex"),
547
+ content: e.toString("hex"),
548
+ });
549
+ }
550
+ _decrypt(s) {
551
+ const p = JSON.parse(s);
552
+ const d = crypto.createDecipheriv(
553
+ "aes-256-gcm",
554
+ this.config.encryptionKey,
555
+ Buffer.from(p.iv, "hex")
556
+ );
557
+ d.setAuthTag(Buffer.from(p.tag, "hex"));
558
+ return JSON.parse(
559
+ Buffer.concat([
560
+ d.update(Buffer.from(p.content, "hex")),
561
+ d.final(),
562
+ ]).toString()
563
+ );
564
+ }
565
+ }
566
+
567
+ module.exports = JSONDatabase;
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Sethunthunder
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sethunthunder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.