liekodb 0.1.2
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/LICENSE +21 -0
- package/README.md +1227 -0
- package/liekodb.js +2014 -0
- package/package.json +29 -0
package/liekodb.js
ADDED
|
@@ -0,0 +1,2014 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const fsSync = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
class CollectionCache {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.storagePath = options.storagePath || './storage';
|
|
12
|
+
this.debug = options.debug || false;
|
|
13
|
+
|
|
14
|
+
this.cache = new Map();
|
|
15
|
+
this.locks = new Map();
|
|
16
|
+
this.dirty = new Set();
|
|
17
|
+
this.pendingSaves = new Set();
|
|
18
|
+
|
|
19
|
+
this.isShuttingDown = false;
|
|
20
|
+
|
|
21
|
+
const autoSaveInterval = options.autoSaveInterval || 5000;
|
|
22
|
+
|
|
23
|
+
if (autoSaveInterval > 0) {
|
|
24
|
+
this.autoSaveTimer = setInterval(() => {
|
|
25
|
+
this._autoSave().catch(console.error);
|
|
26
|
+
}, autoSaveInterval);
|
|
27
|
+
this.autoSaveTimer.unref();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this._setupShutdown();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async _withLock(name, fn) {
|
|
34
|
+
const previous = this.locks.get(name) || Promise.resolve();
|
|
35
|
+
|
|
36
|
+
const next = previous
|
|
37
|
+
.then(fn)
|
|
38
|
+
.catch(err => {
|
|
39
|
+
console.error(`[CollectionCache] ${name} error:`, err);
|
|
40
|
+
throw err;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.locks.set(name, next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _loadUnlocked(name) {
|
|
48
|
+
if (this.cache.has(name)) {
|
|
49
|
+
return this.cache.get(name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const start = Utils.startTimer();
|
|
53
|
+
|
|
54
|
+
const filePath = path.join(this.storagePath, `${name}.json`);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
58
|
+
|
|
59
|
+
let documents = [];
|
|
60
|
+
try {
|
|
61
|
+
documents = JSON.parse(raw) || [];
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`[CollectionCache] Corrupted file ${name}, starting fresh`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const idIndex = new Map();
|
|
67
|
+
documents.forEach((doc, i) => {
|
|
68
|
+
if (doc?.id !== undefined) {
|
|
69
|
+
idIndex.set(doc.id, i);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const data = { documents, idIndex, dirty: false };
|
|
74
|
+
this.cache.set(name, data);
|
|
75
|
+
|
|
76
|
+
if (this.debug) {
|
|
77
|
+
console.log(`[CollectionCache] Loaded ${name} with ${data.documents.length} documents in ${Format.formatDuration(Utils.endTimer(start))}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return data;
|
|
81
|
+
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === 'ENOENT') {
|
|
84
|
+
const data = {
|
|
85
|
+
documents: [],
|
|
86
|
+
idIndex: new Map(),
|
|
87
|
+
dirty: false
|
|
88
|
+
};
|
|
89
|
+
this.cache.set(name, data);
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
reorderDocumentFields = (document) => {
|
|
97
|
+
if (!document || typeof document !== 'object') return document;
|
|
98
|
+
|
|
99
|
+
const orderedDocument = {};
|
|
100
|
+
const reservedFields = ['id', 'createdAt', 'updatedAt'];
|
|
101
|
+
|
|
102
|
+
if (document.id !== undefined) {
|
|
103
|
+
orderedDocument.id = document.id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const normalFields = Object.keys(document)
|
|
107
|
+
.filter(key => !reservedFields.includes(key))
|
|
108
|
+
.sort();
|
|
109
|
+
|
|
110
|
+
for (const key of normalFields) {
|
|
111
|
+
orderedDocument[key] = structuredClone(document[key]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (document.createdAt !== undefined) {
|
|
115
|
+
orderedDocument.createdAt = document.createdAt;
|
|
116
|
+
}
|
|
117
|
+
if (document.updatedAt !== undefined) {
|
|
118
|
+
orderedDocument.updatedAt = document.updatedAt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return orderedDocument;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _saveUnlocked(name, data) {
|
|
125
|
+
const filePath = path.join(this.storagePath, `${name}.json`);
|
|
126
|
+
const tmpPath = `${filePath}.${Date.now()}.tmp`;
|
|
127
|
+
|
|
128
|
+
this.pendingSaves.add(tmpPath);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await fs.mkdir(this.storagePath, { recursive: true });
|
|
132
|
+
|
|
133
|
+
const content =
|
|
134
|
+
'[\n' +
|
|
135
|
+
data.documents.map(doc => JSON.stringify(this.reorderDocumentFields(doc))).join(',\n') +
|
|
136
|
+
'\n]';
|
|
137
|
+
|
|
138
|
+
await fs.writeFile(tmpPath, content, 'utf8');
|
|
139
|
+
|
|
140
|
+
// sanity check
|
|
141
|
+
JSON.parse(await fs.readFile(tmpPath, 'utf8'));
|
|
142
|
+
|
|
143
|
+
await fs.rename(tmpPath, filePath);
|
|
144
|
+
} finally {
|
|
145
|
+
this.pendingSaves.delete(tmpPath);
|
|
146
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async get(name) {
|
|
151
|
+
return this._withLock(name, async () => {
|
|
152
|
+
return this._loadUnlocked(name);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async update(name, updateFn) {
|
|
157
|
+
return this._withLock(name, async () => {
|
|
158
|
+
const data = await this._loadUnlocked(name);
|
|
159
|
+
const changed = await updateFn(data);
|
|
160
|
+
|
|
161
|
+
if (changed !== false) {
|
|
162
|
+
data.dirty = true;
|
|
163
|
+
this.dirty.add(name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return changed;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async updateDocument(name, id, updateFn) {
|
|
171
|
+
return this._withLock(name, async () => {
|
|
172
|
+
const data = await this._loadUnlocked(name);
|
|
173
|
+
|
|
174
|
+
const idx = data.idIndex.get(id);
|
|
175
|
+
if (idx === undefined) {
|
|
176
|
+
throw new Error(`[CollectionCache.updateDocument] Document ${id} not found in ${name}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const updated = {
|
|
180
|
+
...data.documents[idx],
|
|
181
|
+
...updateFn({ ...data.documents[idx] })
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (updated.id !== id) {
|
|
185
|
+
throw new Error('[CollectionCache.update] Updated document id cannot be changed');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
data.documents[idx] = updated;
|
|
189
|
+
data.dirty = true;
|
|
190
|
+
this.dirty.add(name);
|
|
191
|
+
|
|
192
|
+
return updated;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async removeDocument(name, id) {
|
|
197
|
+
return this._withLock(name, async () => {
|
|
198
|
+
const data = await this._loadUnlocked(name);
|
|
199
|
+
|
|
200
|
+
const idx = data.idIndex.get(id);
|
|
201
|
+
if (idx === undefined) return false;
|
|
202
|
+
|
|
203
|
+
data.documents.splice(idx, 1);
|
|
204
|
+
data.idIndex.delete(id);
|
|
205
|
+
|
|
206
|
+
for (let i = idx; i < data.documents.length; i++) {
|
|
207
|
+
const doc = data.documents[i];
|
|
208
|
+
if (doc?.id !== undefined) {
|
|
209
|
+
data.idIndex.set(doc.id, i);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
data.dirty = true;
|
|
214
|
+
this.dirty.add(name);
|
|
215
|
+
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async save(name) {
|
|
221
|
+
const start = Utils.startTimer();
|
|
222
|
+
|
|
223
|
+
return this._withLock(name, async () => {
|
|
224
|
+
const data = this.cache.get(name);
|
|
225
|
+
if (!data || !data.dirty) return false;
|
|
226
|
+
|
|
227
|
+
await this._saveUnlocked(name, data);
|
|
228
|
+
|
|
229
|
+
data.dirty = false;
|
|
230
|
+
this.dirty.delete(name);
|
|
231
|
+
|
|
232
|
+
console.log(`[CollectionCache] Saved ${name}.json with ${data.documents.length} documents in ${Format.formatDuration(Utils.endTimer(start))}`);
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async _autoSave() {
|
|
238
|
+
if (this.isShuttingDown) return;
|
|
239
|
+
if (this.dirty.size === 0) return;
|
|
240
|
+
|
|
241
|
+
for (const name of Array.from(this.dirty)) {
|
|
242
|
+
try {
|
|
243
|
+
await this.save(name);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(`[CollectionCache] Autosave failed for ${name}`, err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async flushAll() {
|
|
251
|
+
this.isShuttingDown = true;
|
|
252
|
+
const start = Utils.startTimer();
|
|
253
|
+
|
|
254
|
+
const savePromises = Array.from(this.dirty).map(name =>
|
|
255
|
+
this.save(name).catch(console.error)
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await Promise.all(savePromises);
|
|
259
|
+
|
|
260
|
+
console.log(`[CollectionCache] Flushed ${savePromises.length} collections in ${Format.formatDuration(Utils.endTimer(start))}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_setupShutdown() {
|
|
264
|
+
if (typeof process === 'undefined') return;
|
|
265
|
+
|
|
266
|
+
const shutdown = async (signal) => {
|
|
267
|
+
if (this.debug) {
|
|
268
|
+
console.log(`[CollectionCache] ${signal} received`);
|
|
269
|
+
}
|
|
270
|
+
await this.flushAll();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
274
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
275
|
+
process.once('beforeExit', () => this.flushAll());
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
class QueryEngine {
|
|
280
|
+
|
|
281
|
+
static applyFilters(data, filter) {
|
|
282
|
+
if (!filter || Object.keys(filter).length === 0) return data;
|
|
283
|
+
|
|
284
|
+
return data.filter(item => this.matchesFilter(item, filter));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
static compareValue(actual, expected) {
|
|
288
|
+
return actual === expected;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
static matchesFilter(item, filter) {
|
|
292
|
+
if (!filter) return true;
|
|
293
|
+
if (filter.$and) return filter.$and.every(f => this.matchesFilter(item, f));
|
|
294
|
+
if (filter.$or) return filter.$or.some(f => this.matchesFilter(item, f));
|
|
295
|
+
if (filter.$nor) return !filter.$nor.some(f => this.matchesFilter(item, f));
|
|
296
|
+
if (filter.$not) return !this.matchesFilter(item, filter.$not);
|
|
297
|
+
|
|
298
|
+
for (const key of Object.keys(filter)) {
|
|
299
|
+
if (key.startsWith('$')) continue;
|
|
300
|
+
|
|
301
|
+
const expected = filter[key];
|
|
302
|
+
const value = this.getValue(item, key);
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
typeof expected === 'object' &&
|
|
306
|
+
expected !== null &&
|
|
307
|
+
!Array.isArray(expected)
|
|
308
|
+
) {
|
|
309
|
+
if (!this.matchesOperators(value, expected)) return false;
|
|
310
|
+
} else {
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
if (!value.includes(expected)) return false;
|
|
313
|
+
} else if (value !== expected) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
static getValue(item, path) {
|
|
323
|
+
if (!path.includes('.')) return item[path];
|
|
324
|
+
|
|
325
|
+
const parts = path.split('.');
|
|
326
|
+
let cur = item;
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < parts.length; i++) {
|
|
329
|
+
const p = parts[i];
|
|
330
|
+
|
|
331
|
+
if (Array.isArray(cur)) {
|
|
332
|
+
const idx = parseInt(p, 10);
|
|
333
|
+
if (!isNaN(idx) && idx >= 0 && idx < cur.length) {
|
|
334
|
+
cur = cur[idx];
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const remainingPath = parts.slice(i).join('.');
|
|
339
|
+
let results = [];
|
|
340
|
+
|
|
341
|
+
for (let el of cur) {
|
|
342
|
+
const v = this.getValue(el, remainingPath);
|
|
343
|
+
if (v !== undefined) {
|
|
344
|
+
if (Array.isArray(v)) {
|
|
345
|
+
results.push(...v);
|
|
346
|
+
} else {
|
|
347
|
+
results.push(v);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return results.length > 0 ? results : undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
356
|
+
cur = cur[p];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return cur;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
static matchesOperators(actual, ops) {
|
|
363
|
+
for (const [op, expected] of Object.entries(ops)) {
|
|
364
|
+
if (op === '$options') continue;
|
|
365
|
+
|
|
366
|
+
if (actual === undefined) {
|
|
367
|
+
switch (op) {
|
|
368
|
+
case '$exists':
|
|
369
|
+
if (expected === true) return false;
|
|
370
|
+
if (expected === false) return true;
|
|
371
|
+
break;
|
|
372
|
+
case '$ne':
|
|
373
|
+
return true;
|
|
374
|
+
default:
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
switch (op) {
|
|
381
|
+
case '$eq':
|
|
382
|
+
if (Array.isArray(actual)) {
|
|
383
|
+
if (!actual.includes(expected)) return false;
|
|
384
|
+
} else if (actual !== expected) return false;
|
|
385
|
+
break;
|
|
386
|
+
|
|
387
|
+
case '$ne':
|
|
388
|
+
if (Array.isArray(actual)) {
|
|
389
|
+
if (actual.includes(expected)) return false;
|
|
390
|
+
} else if (actual === expected) return false;
|
|
391
|
+
break;
|
|
392
|
+
|
|
393
|
+
case '$gt':
|
|
394
|
+
if (Array.isArray(actual)) {
|
|
395
|
+
if (!actual.some(v => v > expected)) return false;
|
|
396
|
+
} else if (!(actual > expected)) return false;
|
|
397
|
+
break;
|
|
398
|
+
|
|
399
|
+
case '$gte':
|
|
400
|
+
if (Array.isArray(actual)) {
|
|
401
|
+
if (!actual.some(v => v >= expected)) return false;
|
|
402
|
+
} else if (!(actual >= expected)) return false;
|
|
403
|
+
break;
|
|
404
|
+
|
|
405
|
+
case '$lt':
|
|
406
|
+
if (Array.isArray(actual)) {
|
|
407
|
+
if (!actual.some(v => v < expected)) return false;
|
|
408
|
+
} else if (!(actual < expected)) return false;
|
|
409
|
+
break;
|
|
410
|
+
|
|
411
|
+
case '$lte':
|
|
412
|
+
if (Array.isArray(actual)) {
|
|
413
|
+
if (!actual.some(v => v <= expected)) return false;
|
|
414
|
+
} else if (!(actual <= expected)) return false;
|
|
415
|
+
break;
|
|
416
|
+
|
|
417
|
+
case '$in':
|
|
418
|
+
if (Array.isArray(actual)) {
|
|
419
|
+
if (!actual.some(v => expected.includes(v))) return false;
|
|
420
|
+
} else {
|
|
421
|
+
if (!expected.includes(actual)) return false;
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
|
|
425
|
+
case '$nin':
|
|
426
|
+
if (Array.isArray(actual)) {
|
|
427
|
+
if (actual.some(v => expected.includes(v))) return false;
|
|
428
|
+
} else {
|
|
429
|
+
if (expected.includes(actual)) return false;
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
|
|
433
|
+
case '$exists':
|
|
434
|
+
if (expected === true && actual === undefined) return false;
|
|
435
|
+
if (expected === false && actual !== undefined) return false;
|
|
436
|
+
break;
|
|
437
|
+
|
|
438
|
+
case '$not':
|
|
439
|
+
return !this.matchesOperators(actual, expected);
|
|
440
|
+
|
|
441
|
+
case '$regex':
|
|
442
|
+
try {
|
|
443
|
+
const pattern = expected instanceof RegExp ? expected : new RegExp(expected, ops.$options || '');
|
|
444
|
+
if (Array.isArray(actual)) {
|
|
445
|
+
if (!actual.some(v => pattern.test(String(v)))) return false;
|
|
446
|
+
} else {
|
|
447
|
+
if (!pattern.test(String(actual))) return false;
|
|
448
|
+
}
|
|
449
|
+
} catch (e) {
|
|
450
|
+
console.warn('Invalid regex pattern:', expected);
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case '$mod':
|
|
456
|
+
if (!Array.isArray(expected) || expected.length !== 2) {
|
|
457
|
+
console.warn("Invalid $mod operator:", expected);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
const [div, rem] = expected;
|
|
461
|
+
if (Array.isArray(actual)) {
|
|
462
|
+
if (!actual.some(v => typeof v === 'number' && v % div === rem)) return false;
|
|
463
|
+
} else {
|
|
464
|
+
if (typeof actual !== 'number') return false;
|
|
465
|
+
if (actual % div !== rem) return false;
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
|
|
469
|
+
default:
|
|
470
|
+
if (this.debug) console.warn("Unknown operator:", op);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
static count(documents, filters = {}) {
|
|
478
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
479
|
+
return documents.length;
|
|
480
|
+
}
|
|
481
|
+
return this.applyFilters(documents, filters).length;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
static sortResults(data, sortSpec) {
|
|
485
|
+
return data.sort((a, b) => {
|
|
486
|
+
for (const [field, direction] of Object.entries(sortSpec)) {
|
|
487
|
+
const aVal = this.getValue(a, field);
|
|
488
|
+
const bVal = this.getValue(b, field);
|
|
489
|
+
|
|
490
|
+
if (aVal < bVal) return direction === 1 ? -1 : 1;
|
|
491
|
+
if (aVal > bVal) return direction === 1 ? 1 : -1;
|
|
492
|
+
}
|
|
493
|
+
return 0;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
static selectFields(data, projection) {
|
|
498
|
+
if (!projection || Object.keys(projection).length === 0) {
|
|
499
|
+
return data;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (data == null) {
|
|
503
|
+
return data;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const hasIncludeFields = Object.values(projection).some(v => v === 1 || v === true);
|
|
507
|
+
const hasExcludeFields = Object.values(projection).some(v => v === -1 || v === false);
|
|
508
|
+
|
|
509
|
+
const projectSingleDoc = (doc) => {
|
|
510
|
+
if (!doc || typeof doc !== 'object') {
|
|
511
|
+
return doc;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (hasIncludeFields && !hasExcludeFields) {
|
|
515
|
+
const result = {};
|
|
516
|
+
for (const [field, include] of Object.entries(projection)) {
|
|
517
|
+
if (include === 1 || include === true) {
|
|
518
|
+
result[field] = this.getValue(doc, field);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (hasExcludeFields && !hasIncludeFields) {
|
|
525
|
+
const result = { ...doc };
|
|
526
|
+
for (const [field, exclude] of Object.entries(projection)) {
|
|
527
|
+
if (exclude === -1 || exclude === false) {
|
|
528
|
+
const parts = field.split('.');
|
|
529
|
+
if (parts.length === 1) {
|
|
530
|
+
delete result[field];
|
|
531
|
+
} else {
|
|
532
|
+
this.removeFieldByPath(result, field);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.warn('Mixed inclusion/exclusion in projection not supported. Returning full document.');
|
|
540
|
+
return doc;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
if (Array.isArray(data)) {
|
|
544
|
+
return data.map(projectSingleDoc);
|
|
545
|
+
} else if (typeof data === 'object') {
|
|
546
|
+
return projectSingleDoc(data);
|
|
547
|
+
} else {
|
|
548
|
+
return data;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
static removeFieldByPath(obj, path) {
|
|
553
|
+
const parts = path.split('.');
|
|
554
|
+
let current = obj;
|
|
555
|
+
|
|
556
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
557
|
+
const part = parts[i];
|
|
558
|
+
if (current[part] === undefined || typeof current[part] !== 'object') {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
current = current[part];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const lastPart = parts[parts.length - 1];
|
|
565
|
+
delete current[lastPart];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
static applyUpdateToDoc(doc, update) {
|
|
569
|
+
if (!update) return;
|
|
570
|
+
|
|
571
|
+
const applyNestedOperation = (obj, path, operation, value) => {
|
|
572
|
+
const parts = path.split('.');
|
|
573
|
+
let current = obj;
|
|
574
|
+
|
|
575
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
576
|
+
const part = parts[i];
|
|
577
|
+
if (current[part] === undefined || typeof current[part] !== 'object') {
|
|
578
|
+
current[part] = {};
|
|
579
|
+
}
|
|
580
|
+
current = current[part];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const lastPart = parts[parts.length - 1];
|
|
584
|
+
|
|
585
|
+
switch (operation) {
|
|
586
|
+
case 'set':
|
|
587
|
+
current[lastPart] = value;
|
|
588
|
+
break;
|
|
589
|
+
case 'unset':
|
|
590
|
+
delete current[lastPart];
|
|
591
|
+
break;
|
|
592
|
+
case 'inc':
|
|
593
|
+
current[lastPart] = (typeof current[lastPart] === 'number' ? current[lastPart] : 0) + value;
|
|
594
|
+
break;
|
|
595
|
+
case 'push':
|
|
596
|
+
if (!Array.isArray(current[lastPart])) current[lastPart] = [];
|
|
597
|
+
current[lastPart].push(value);
|
|
598
|
+
break;
|
|
599
|
+
case 'addToSet':
|
|
600
|
+
if (!Array.isArray(current[lastPart])) current[lastPart] = [];
|
|
601
|
+
if (!current[lastPart].includes(value)) {
|
|
602
|
+
current[lastPart].push(value);
|
|
603
|
+
}
|
|
604
|
+
break;
|
|
605
|
+
case 'pull':
|
|
606
|
+
if (Array.isArray(current[lastPart])) {
|
|
607
|
+
current[lastPart] = current[lastPart].filter(item => item !== value);
|
|
608
|
+
}
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const hasRootLevelOperators =
|
|
614
|
+
'$set' in update ||
|
|
615
|
+
'$unset' in update ||
|
|
616
|
+
'$inc' in update ||
|
|
617
|
+
'$push' in update ||
|
|
618
|
+
'$pull' in update ||
|
|
619
|
+
'$addToSet' in update;
|
|
620
|
+
|
|
621
|
+
if (hasRootLevelOperators) {
|
|
622
|
+
if (update.$set) {
|
|
623
|
+
for (const [k, v] of Object.entries(update.$set)) {
|
|
624
|
+
if (k.includes('.')) {
|
|
625
|
+
applyNestedOperation(doc, k, 'set', v);
|
|
626
|
+
} else {
|
|
627
|
+
doc[k] = v;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (update.$unset) {
|
|
633
|
+
for (const k of Object.keys(update.$unset)) {
|
|
634
|
+
if (k.includes('.')) {
|
|
635
|
+
applyNestedOperation(doc, k, 'unset', null);
|
|
636
|
+
} else {
|
|
637
|
+
delete doc[k];
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (update.$inc) {
|
|
643
|
+
for (const [k, v] of Object.entries(update.$inc)) {
|
|
644
|
+
if (k.includes('.')) {
|
|
645
|
+
applyNestedOperation(doc, k, 'inc', v);
|
|
646
|
+
} else {
|
|
647
|
+
doc[k] = (typeof doc[k] === 'number' ? doc[k] : 0) + v;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (update.$push) {
|
|
653
|
+
for (const [k, v] of Object.entries(update.$push)) {
|
|
654
|
+
if (k.includes('.')) {
|
|
655
|
+
applyNestedOperation(doc, k, 'push', v);
|
|
656
|
+
} else {
|
|
657
|
+
if (!Array.isArray(doc[k])) doc[k] = [];
|
|
658
|
+
doc[k].push(v);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (update.$addToSet) {
|
|
664
|
+
for (const [k, v] of Object.entries(update.$addToSet)) {
|
|
665
|
+
if (k.includes('.')) {
|
|
666
|
+
applyNestedOperation(doc, k, 'addToSet', v);
|
|
667
|
+
} else {
|
|
668
|
+
if (!Array.isArray(doc[k])) doc[k] = [];
|
|
669
|
+
if (v && typeof v === 'object' && v.$each) {
|
|
670
|
+
for (const item of v.$each) {
|
|
671
|
+
if (!doc[k].includes(item)) {
|
|
672
|
+
doc[k].push(item);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
if (!doc[k].includes(v)) {
|
|
677
|
+
doc[k].push(v);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (update.$pull) {
|
|
685
|
+
for (const [k, v] of Object.entries(update.$pull)) {
|
|
686
|
+
if (k.includes('.')) {
|
|
687
|
+
applyNestedOperation(doc, k, 'pull', v);
|
|
688
|
+
} else {
|
|
689
|
+
if (Array.isArray(doc[k])) {
|
|
690
|
+
doc[k] = doc[k].filter(item => {
|
|
691
|
+
if (typeof v === 'object' && v.$in) {
|
|
692
|
+
return !v.$in.includes(item);
|
|
693
|
+
}
|
|
694
|
+
return item !== v;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
Object.assign(doc, update);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
doc.updatedAt = new Date().toISOString();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
class HTTPAdapter {
|
|
709
|
+
constructor(opts = {}) {
|
|
710
|
+
this.poolSize = opts.poolSize || 10;
|
|
711
|
+
this.requestQueue = [];
|
|
712
|
+
this.activeRequests = 0;
|
|
713
|
+
this.maxRetries = opts.maxRetries || 3;
|
|
714
|
+
this.timeout = opts.timeout || 15000;
|
|
715
|
+
this.databaseUrl = opts.databaseUrl || 'http://127.0.0.1:8050';
|
|
716
|
+
this.token = opts.token || null;
|
|
717
|
+
this.parsedBaseUrl = new URL(this.databaseUrl);
|
|
718
|
+
this.isHttps = this.parsedBaseUrl.protocol === 'https:';
|
|
719
|
+
this.hostname = ['localhost', '127.0.0.1'].includes(this.parsedBaseUrl.hostname)
|
|
720
|
+
? '127.0.0.1'
|
|
721
|
+
: this.parsedBaseUrl.hostname;
|
|
722
|
+
|
|
723
|
+
const isLocal = ['localhost', '127.0.0.1'].includes(this.parsedBaseUrl.hostname);
|
|
724
|
+
|
|
725
|
+
const agentOptions = {
|
|
726
|
+
keepAlive: !isLocal, // désactivé en local → pas utile
|
|
727
|
+
keepAliveMsecs: 1000,
|
|
728
|
+
maxSockets: this.poolSize,
|
|
729
|
+
maxFreeSockets: this.poolSize,
|
|
730
|
+
timeout: this.timeout,
|
|
731
|
+
scheduling: 'lifo'
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
this.httpAgent = new http.Agent(agentOptions);
|
|
735
|
+
this.httpsAgent = new https.Agent(agentOptions);
|
|
736
|
+
|
|
737
|
+
// Configuration des sockets
|
|
738
|
+
const setupSocket = (socket) => {
|
|
739
|
+
socket.setNoDelay(true); // toujours utile (même en local)
|
|
740
|
+
if (!isLocal) {
|
|
741
|
+
socket.setKeepAlive(true, 1000); // seulement en distant
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
this.httpAgent.on('socket', setupSocket);
|
|
746
|
+
this.httpsAgent.on('socket', setupSocket);
|
|
747
|
+
|
|
748
|
+
this.baseHeaders = {
|
|
749
|
+
"Content-Type": "application/json"
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
if (this.token) {
|
|
753
|
+
this.baseHeaders.Authorization = `Bearer ${this.token}`;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async request(method, endpoint, data = {}) {
|
|
758
|
+
return new Promise((resolve, reject) => {
|
|
759
|
+
this._enqueue({ method, endpoint, data, resolve, reject, retries: 0 });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
_enqueue(req) {
|
|
764
|
+
this.requestQueue.push(req);
|
|
765
|
+
this._processQueue();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
_processQueue() {
|
|
769
|
+
if (this.activeRequests >= this.poolSize || this.requestQueue.length === 0) return;
|
|
770
|
+
|
|
771
|
+
const req = this.requestQueue.shift();
|
|
772
|
+
this.activeRequests++;
|
|
773
|
+
|
|
774
|
+
this._execute(req)
|
|
775
|
+
.then(req.resolve)
|
|
776
|
+
.catch(err => {
|
|
777
|
+
if (req.retries < this.maxRetries && this._retryable(err)) {
|
|
778
|
+
req.retries++;
|
|
779
|
+
this.requestQueue.unshift(req);
|
|
780
|
+
} else {
|
|
781
|
+
req.reject(err);
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
.finally(() => {
|
|
785
|
+
this.activeRequests--;
|
|
786
|
+
setImmediate(() => this._processQueue());
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async _execute(req) {
|
|
791
|
+
let path = req.endpoint;
|
|
792
|
+
let body = null;
|
|
793
|
+
|
|
794
|
+
const headers = { ...this.baseHeaders };
|
|
795
|
+
|
|
796
|
+
// 🔹 GET / HEAD → query string
|
|
797
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
798
|
+
if (req.data && Object.keys(req.data).length > 0) {
|
|
799
|
+
const query = new URLSearchParams();
|
|
800
|
+
|
|
801
|
+
for (const [key, value] of Object.entries(req.data)) {
|
|
802
|
+
query.append(
|
|
803
|
+
key,
|
|
804
|
+
typeof value === "object"
|
|
805
|
+
? JSON.stringify(value)
|
|
806
|
+
: String(value)
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
path += `?${query.toString()}`;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// 🔹 Autres méthodes → body JSON
|
|
814
|
+
else {
|
|
815
|
+
body = JSON.stringify(req.data || {});
|
|
816
|
+
headers["Content-Length"] = Buffer.byteLength(body);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const options = {
|
|
820
|
+
method: req.method,
|
|
821
|
+
hostname: this.hostname,
|
|
822
|
+
port: this.parsedBaseUrl.port,
|
|
823
|
+
path,
|
|
824
|
+
headers,
|
|
825
|
+
agent: this.isHttps ? this.httpsAgent : this.httpAgent
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
return new Promise((resolve, reject) => {
|
|
829
|
+
const start = performance.now();
|
|
830
|
+
const transport = this.isHttps ? https : http;
|
|
831
|
+
|
|
832
|
+
const request = transport.request(options, res => {
|
|
833
|
+
const chunks = [];
|
|
834
|
+
|
|
835
|
+
res.on("data", c => chunks.push(c));
|
|
836
|
+
res.on("end", () => {
|
|
837
|
+
const raw = Buffer.concat(chunks);
|
|
838
|
+
const size = raw.length;
|
|
839
|
+
|
|
840
|
+
let parsed = raw.toString();
|
|
841
|
+
if (res.headers["content-type"]?.includes("application/json")) {
|
|
842
|
+
try { parsed = JSON.parse(parsed); } catch { }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
this._log(req, start, size, res.statusCode);
|
|
846
|
+
|
|
847
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
848
|
+
resolve(parsed);
|
|
849
|
+
} else {
|
|
850
|
+
const RED = '\x1b[31m';
|
|
851
|
+
const RESET = '\x1b[0m';
|
|
852
|
+
|
|
853
|
+
reject(`${RED}[ERROR] ${parsed.error?.code} ${parsed.error?.message || `HTTP ${res.statusCode}`}${RESET}`);
|
|
854
|
+
/*
|
|
855
|
+
reject(
|
|
856
|
+
new Error(
|
|
857
|
+
`HTTP ${res.statusCode}: ${parsed?.error.message || parsed
|
|
858
|
+
}`
|
|
859
|
+
)
|
|
860
|
+
);*/
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
request.on("error", err => {
|
|
866
|
+
this._log(req, start, 0, "ERROR", err.message);
|
|
867
|
+
reject(err);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
request.setTimeout(this.timeout, () => {
|
|
871
|
+
request.destroy();
|
|
872
|
+
reject(new Error("Request timeout"));
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
if (body) request.end(body);
|
|
876
|
+
else request.end();
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
_retryable(err) {
|
|
881
|
+
if (!err || !err.message) return false;
|
|
882
|
+
return (
|
|
883
|
+
err.message.includes("timeout") ||
|
|
884
|
+
err.message.includes("ECONNRESET") ||
|
|
885
|
+
err.message.includes("ECONNREFUSED") ||
|
|
886
|
+
err.message.includes("EAI_AGAIN")
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
_log(req, start, size, status, error = null) {
|
|
891
|
+
const ms = (Math.round((performance.now() - start) * 1000) / 1000).toFixed(2);
|
|
892
|
+
const op = req.endpoint.split("/")[2]?.toUpperCase() || "REQUEST";
|
|
893
|
+
|
|
894
|
+
if (status === "ERROR") {
|
|
895
|
+
console.log(
|
|
896
|
+
`[HTTP] ${op} ${req.endpoint} | Error: ${error} | Duration: ${ms}ms`
|
|
897
|
+
);
|
|
898
|
+
} else {
|
|
899
|
+
console.log(
|
|
900
|
+
`[HTTP] ${op} | ${req.method} -> ${req.endpoint} | Status: ${status} | Duration: ${ms}ms | Size: ${size}B`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
close() {
|
|
906
|
+
this.httpAgent.destroy();
|
|
907
|
+
this.httpsAgent.destroy();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
class LocalAdapter {
|
|
912
|
+
constructor(options = {}) {
|
|
913
|
+
this.storagePath = options.storagePath || './storage';
|
|
914
|
+
this.debug = options.debug || false;
|
|
915
|
+
|
|
916
|
+
this.cache = new CollectionCache({
|
|
917
|
+
storagePath: this.storagePath,
|
|
918
|
+
autoSaveInterval: options.autoSaveInterval || 5000,
|
|
919
|
+
debug: options.debug
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
this.collectionName = null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
listCollections() {
|
|
926
|
+
const collections = [];
|
|
927
|
+
|
|
928
|
+
try {
|
|
929
|
+
const files = fsSync.readdirSync(this.storagePath);
|
|
930
|
+
|
|
931
|
+
for (const file of files) {
|
|
932
|
+
if (file.endsWith('.json')) {
|
|
933
|
+
const collectionName = path.basename(file, '.json');
|
|
934
|
+
const filePath = path.join(this.storagePath, file);
|
|
935
|
+
|
|
936
|
+
let totalDocuments = 0;
|
|
937
|
+
let fileSize = 0;
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
const stats = fsSync.statSync(filePath);
|
|
941
|
+
fileSize = stats.size;
|
|
942
|
+
|
|
943
|
+
const content = fsSync.readFileSync(filePath, 'utf-8');
|
|
944
|
+
if (content.trim() === '') {
|
|
945
|
+
totalDocuments = 0;
|
|
946
|
+
} else {
|
|
947
|
+
const data = JSON.parse(content);
|
|
948
|
+
if (Array.isArray(data)) {
|
|
949
|
+
totalDocuments = data.length;
|
|
950
|
+
} else {
|
|
951
|
+
totalDocuments = Object.keys(data).length;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
} catch (parseErr) {
|
|
955
|
+
console.error(`Error while reading ${collectionName}:`, parseErr);
|
|
956
|
+
totalDocuments = 'error';
|
|
957
|
+
fileSize = 'error';
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
collections.push({
|
|
961
|
+
name: collectionName,
|
|
962
|
+
totalDocuments,
|
|
963
|
+
sizeBytes: fileSize,
|
|
964
|
+
sizeFormatted: Format.formatBytes(fileSize)
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} catch (err) {
|
|
969
|
+
console.error(`Error while reading storage path (${this.storagePath})`, err);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return collections.sort((a, b) => a.name.localeCompare(b.name));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async status() {
|
|
976
|
+
const collections = this.listCollections();
|
|
977
|
+
|
|
978
|
+
const totalDocuments = collections.reduce((sum, col) => {
|
|
979
|
+
return sum + (typeof col.totalDocuments === 'number' ? col.totalDocuments : 0);
|
|
980
|
+
}, 0);
|
|
981
|
+
|
|
982
|
+
const totalCollectionsSize = collections.reduce((sum, col) => {
|
|
983
|
+
return sum + (typeof col.sizeBytes === 'number' ? col.sizeBytes : 0);
|
|
984
|
+
}, 0);
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
storagePath: this.storagePath,
|
|
988
|
+
collections,
|
|
989
|
+
totalCollections: collections.length,
|
|
990
|
+
totalDocuments,
|
|
991
|
+
totalCollectionsSize: totalCollectionsSize,
|
|
992
|
+
totalCollectionsSizeFormatted: Format.formatBytes(totalCollectionsSize)
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async request(method, endpoint, payload = {}) {
|
|
997
|
+
const parts = endpoint.split("/").filter(Boolean);
|
|
998
|
+
// Payload can contains filters, options, data, update
|
|
999
|
+
|
|
1000
|
+
this.collectionName = parts[1];
|
|
1001
|
+
const param = parts[2];
|
|
1002
|
+
|
|
1003
|
+
if (method === "GET" && !param) return this.find(payload);
|
|
1004
|
+
if (method === "GET" && param === "count") return this.count(payload);
|
|
1005
|
+
if (method === "GET" && param) return this.findById(param, payload);
|
|
1006
|
+
|
|
1007
|
+
if (method === "POST") return this.insert(payload);
|
|
1008
|
+
|
|
1009
|
+
if (method === "PATCH" && param) return this.updateById(param, payload);
|
|
1010
|
+
if (method === "PATCH") return this.update(payload);
|
|
1011
|
+
|
|
1012
|
+
if (method === "DELETE" && param === "drop") return this.dropCollection();
|
|
1013
|
+
if (method === "DELETE" && param) return this.deleteById(param);
|
|
1014
|
+
if (method === "DELETE") return this.delete(payload);
|
|
1015
|
+
|
|
1016
|
+
throw new Error(`Unsupported endpoint: ${method} ${endpoint}`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async count({ filters = {} } = {}) {
|
|
1020
|
+
try {
|
|
1021
|
+
const start = Utils.startTimer();
|
|
1022
|
+
const collection = await this.cache.get(this.collectionName);
|
|
1023
|
+
const count = QueryEngine.count(collection.documents, filters);
|
|
1024
|
+
|
|
1025
|
+
const duration = Utils.endTimer(start);
|
|
1026
|
+
const details = `Filters: ${Format.formatFilters(filters)} | Count: ${count}`;
|
|
1027
|
+
Utils.logRequest('count', details, duration, Utils.getDataSize(count));
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
data: count
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
Utils.logError('count', err);
|
|
1035
|
+
return {
|
|
1036
|
+
error: {
|
|
1037
|
+
message: err.message || 'Failed to count documents',
|
|
1038
|
+
code: 500
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async find({ filters = {}, options = {} } = {}) {
|
|
1045
|
+
const start = Utils.startTimer();
|
|
1046
|
+
try {
|
|
1047
|
+
const collection = await this.cache.get(this.collectionName);
|
|
1048
|
+
|
|
1049
|
+
let limitNum = null;
|
|
1050
|
+
let skipNum = 0;
|
|
1051
|
+
|
|
1052
|
+
if (options.limit) {
|
|
1053
|
+
limitNum = parseInt(options.limit, 10);
|
|
1054
|
+
if (isNaN(limitNum) || limitNum < 0) {
|
|
1055
|
+
throw new Error('Limit must be a positive number');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (options.skip !== undefined) {
|
|
1059
|
+
skipNum = parseInt(options.skip, 10);
|
|
1060
|
+
if (isNaN(skipNum) || skipNum < 0) {
|
|
1061
|
+
throw new Error('Skip cannot be negative');
|
|
1062
|
+
}
|
|
1063
|
+
} else if (options.page !== undefined) {
|
|
1064
|
+
const pageNum = parseInt(options.page, 10);
|
|
1065
|
+
if (isNaN(pageNum) || pageNum < 1) {
|
|
1066
|
+
throw new Error('Page must be >= 1');
|
|
1067
|
+
}
|
|
1068
|
+
skipNum = (pageNum - 1) * limitNum;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const filteredDocuments = QueryEngine.applyFilters(
|
|
1073
|
+
collection.documents,
|
|
1074
|
+
filters
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
let documents = [...filteredDocuments];
|
|
1078
|
+
|
|
1079
|
+
if (options.sort) {
|
|
1080
|
+
if (typeof options.sort !== 'object') {
|
|
1081
|
+
throw new Error('Sort must be an object');
|
|
1082
|
+
}
|
|
1083
|
+
documents = QueryEngine.sortResults(documents, options.sort);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (limitNum !== null) {
|
|
1087
|
+
documents = documents.slice(skipNum, skipNum + limitNum);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (options.fields) {
|
|
1091
|
+
if (typeof options.fields !== 'object') {
|
|
1092
|
+
throw new Error('Fields must be an object');
|
|
1093
|
+
}
|
|
1094
|
+
documents = QueryEngine.selectFields(documents, options.fields);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const totalFilteredDocuments = filteredDocuments.length;
|
|
1098
|
+
const returnedCount = documents.length;
|
|
1099
|
+
const duration = Utils.endTimer(start);
|
|
1100
|
+
|
|
1101
|
+
const response = {
|
|
1102
|
+
data: {
|
|
1103
|
+
foundCount: documents.length,
|
|
1104
|
+
foundDocuments: documents,
|
|
1105
|
+
totalDocuments: collection.documents.length
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
if (limitNum !== null) {
|
|
1110
|
+
const page = Math.floor(skipNum / limitNum) + 1;
|
|
1111
|
+
const totalPages = Math.max(1, Math.ceil(totalFilteredDocuments / limitNum));
|
|
1112
|
+
|
|
1113
|
+
response.data.isPaginated = true;
|
|
1114
|
+
response.data.pagination = {
|
|
1115
|
+
page,
|
|
1116
|
+
limit: limitNum,
|
|
1117
|
+
skip: skipNum,
|
|
1118
|
+
|
|
1119
|
+
total: totalFilteredDocuments,
|
|
1120
|
+
totalPages,
|
|
1121
|
+
|
|
1122
|
+
hasNext: page < totalPages,
|
|
1123
|
+
hasPrev: page > 1,
|
|
1124
|
+
|
|
1125
|
+
nextPage: page < totalPages ? page + 1 : null,
|
|
1126
|
+
prevPage: page > 1 ? page - 1 : null,
|
|
1127
|
+
|
|
1128
|
+
startIndex: totalFilteredDocuments > 0 ? skipNum + 1 : 0,
|
|
1129
|
+
endIndex: Math.min(skipNum + returnedCount, totalFilteredDocuments)
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
let pageInfo = '';
|
|
1134
|
+
|
|
1135
|
+
if (limitNum !== null) {
|
|
1136
|
+
const page = Math.floor(skipNum / limitNum) + 1;
|
|
1137
|
+
const totalPages = Math.max(1, Math.ceil(totalFilteredDocuments / limitNum));
|
|
1138
|
+
pageInfo = ` | Page: ${page}/${totalPages}`;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const details =
|
|
1142
|
+
`Filters: ${Format.formatFilters(filters)}` +
|
|
1143
|
+
`${Format.formatOptions(options)}` +
|
|
1144
|
+
`${pageInfo} | Found: ${totalFilteredDocuments} | Returned: ${returnedCount}`;
|
|
1145
|
+
|
|
1146
|
+
Utils.logRequest(
|
|
1147
|
+
'find',
|
|
1148
|
+
details,
|
|
1149
|
+
duration,
|
|
1150
|
+
Utils.getDataSize(response)
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
return response;
|
|
1154
|
+
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
Utils.logError('find', err);
|
|
1157
|
+
|
|
1158
|
+
return {
|
|
1159
|
+
error: {
|
|
1160
|
+
message: err.message || 'An unexpected error occurred during find',
|
|
1161
|
+
code: 500
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async findById(id, { options = {} } = {}) {
|
|
1168
|
+
try {
|
|
1169
|
+
const start = Utils.startTimer();
|
|
1170
|
+
const collectionData = await this.cache.get(this.collectionName);
|
|
1171
|
+
let details = '';
|
|
1172
|
+
|
|
1173
|
+
let document = null;
|
|
1174
|
+
if (collectionData.idIndex?.has(id)) {
|
|
1175
|
+
const idx = collectionData.idIndex.get(id);
|
|
1176
|
+
document = collectionData.documents[idx];
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (options.fields && document) {
|
|
1180
|
+
document = QueryEngine.selectFields(document, options.fields);
|
|
1181
|
+
details = `ID: ${id} | ${Format.formatFieldsLog(options.fields)} | Found: ${document ? 'Yes' : 'No'}`;
|
|
1182
|
+
} else {
|
|
1183
|
+
details = `ID: ${id} | Found: ${document ? 'Yes' : 'No'}`;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const duration = Utils.endTimer(start);
|
|
1187
|
+
Utils.logRequest('find_By_Id', details, duration, Utils.getDataSize(document));
|
|
1188
|
+
|
|
1189
|
+
return {
|
|
1190
|
+
data: document ?? null
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
Utils.logError('findById', err);
|
|
1195
|
+
return {
|
|
1196
|
+
error: {
|
|
1197
|
+
message: err.message || 'Internal error during findById',
|
|
1198
|
+
code: 500
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async insert({ documents }) {
|
|
1205
|
+
const start = Utils.startTimer();
|
|
1206
|
+
try {
|
|
1207
|
+
const documentsToInsert = Array.isArray(documents) ? documents : [documents];
|
|
1208
|
+
const now = new Date().toISOString();
|
|
1209
|
+
|
|
1210
|
+
let totalDocuments = 0;
|
|
1211
|
+
const inserted = [];
|
|
1212
|
+
const updated = [];
|
|
1213
|
+
|
|
1214
|
+
await this.cache.update(this.collectionName, async (data) => {
|
|
1215
|
+
totalDocuments = data.documents.length;
|
|
1216
|
+
const insertCount = documentsToInsert.length;
|
|
1217
|
+
const useSequentialIds = insertCount >= 2;
|
|
1218
|
+
|
|
1219
|
+
let prefix = null;
|
|
1220
|
+
let sequence = 0;
|
|
1221
|
+
|
|
1222
|
+
if (useSequentialIds) {
|
|
1223
|
+
prefix = Date.now().toString(36);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
for (let document of documentsToInsert) {
|
|
1227
|
+
let docId = document.id;
|
|
1228
|
+
|
|
1229
|
+
if (!docId) {
|
|
1230
|
+
docId = useSequentialIds
|
|
1231
|
+
? `${prefix}_${++sequence}`
|
|
1232
|
+
: Utils.generateId();
|
|
1233
|
+
document.id = String(docId);
|
|
1234
|
+
} else {
|
|
1235
|
+
document.id = String(docId);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (data.idIndex.has(document.id)) {
|
|
1239
|
+
// UPDATE existing document
|
|
1240
|
+
const existingIndex = data.idIndex.get(document.id);
|
|
1241
|
+
const existingDoc = data.documents[existingIndex];
|
|
1242
|
+
const originalCreatedAt = existingDoc.createdAt;
|
|
1243
|
+
|
|
1244
|
+
for (const [key, value] of Object.entries(document)) {
|
|
1245
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
1246
|
+
existingDoc[key] = {
|
|
1247
|
+
...(existingDoc[key] || {}),
|
|
1248
|
+
...value
|
|
1249
|
+
};
|
|
1250
|
+
} else {
|
|
1251
|
+
existingDoc[key] = value;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
existingDoc.createdAt = originalCreatedAt;
|
|
1256
|
+
existingDoc.updatedAt = now;
|
|
1257
|
+
|
|
1258
|
+
updated.push(existingDoc);
|
|
1259
|
+
} else {
|
|
1260
|
+
// INSERT new document
|
|
1261
|
+
document.createdAt = now;
|
|
1262
|
+
document.updatedAt = now;
|
|
1263
|
+
|
|
1264
|
+
const newIndex = data.documents.length;
|
|
1265
|
+
data.documents.push(document);
|
|
1266
|
+
data.idIndex.set(document.id, newIndex);
|
|
1267
|
+
|
|
1268
|
+
inserted.push(document);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return { inserted, updated };
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
const responseData = {
|
|
1276
|
+
insertedCount: inserted.length,
|
|
1277
|
+
updatedCount: updated.length,
|
|
1278
|
+
totalDocuments: totalDocuments + inserted.length
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
if (inserted.length > 0) {
|
|
1282
|
+
if (inserted.length > 20) {
|
|
1283
|
+
responseData.firstId = inserted[0].id;
|
|
1284
|
+
responseData.lastId = inserted[inserted.length - 1].id;
|
|
1285
|
+
} else {
|
|
1286
|
+
responseData.insertedIds = inserted.map(d => d.id);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const duration = Utils.endTimer(start);
|
|
1291
|
+
const details = updated.length > 0
|
|
1292
|
+
? `Inserted: ${inserted.length}, Updated: ${updated.length}`
|
|
1293
|
+
: `Inserted: ${inserted.length}`;
|
|
1294
|
+
Utils.logRequest('insert', details, duration, Utils.getDataSize(responseData));
|
|
1295
|
+
|
|
1296
|
+
return { data: responseData };
|
|
1297
|
+
|
|
1298
|
+
} catch (err) {
|
|
1299
|
+
Utils.logError('insert', err);
|
|
1300
|
+
return {
|
|
1301
|
+
error: {
|
|
1302
|
+
message: err.message || 'Failed to insert documents',
|
|
1303
|
+
code: 500
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async update({ filters = {}, update = {}, options = {} } = {}) {
|
|
1310
|
+
try {
|
|
1311
|
+
const start = Utils.startTimer();
|
|
1312
|
+
|
|
1313
|
+
const normalizedUpdate = update.$set || update.$inc ||
|
|
1314
|
+
update.$push || update.$pull ||
|
|
1315
|
+
update.$unset || update.$addToSet
|
|
1316
|
+
? update
|
|
1317
|
+
: { $set: update };
|
|
1318
|
+
|
|
1319
|
+
const returnType = options.returnType || 'count';
|
|
1320
|
+
const maxReturn = options.maxReturn ?? 50;
|
|
1321
|
+
|
|
1322
|
+
let updated = 0;
|
|
1323
|
+
const allUpdatedDocs = [];
|
|
1324
|
+
|
|
1325
|
+
const result = await this.cache.update(this.collectionName, async (data) => {
|
|
1326
|
+
for (let i = 0; i < data.documents.length; i++) {
|
|
1327
|
+
if (QueryEngine.matchesFilter(data.documents[i], filters)) {
|
|
1328
|
+
const before = returnType !== 'count'
|
|
1329
|
+
? JSON.parse(JSON.stringify(data.documents[i]))
|
|
1330
|
+
: null;
|
|
1331
|
+
|
|
1332
|
+
QueryEngine.applyUpdateToDoc(data.documents[i], normalizedUpdate);
|
|
1333
|
+
data.documents[i].updatedAt = new Date().toISOString();
|
|
1334
|
+
|
|
1335
|
+
updated++;
|
|
1336
|
+
|
|
1337
|
+
if (returnType !== 'count') {
|
|
1338
|
+
allUpdatedDocs.push({
|
|
1339
|
+
before,
|
|
1340
|
+
after: data.documents[i]
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return {
|
|
1347
|
+
totalDocuments: data.documents.length,
|
|
1348
|
+
updated,
|
|
1349
|
+
allUpdatedDocs
|
|
1350
|
+
};
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
let responseData = {
|
|
1354
|
+
updatedCount: result.updated,
|
|
1355
|
+
totalDocuments: result.totalDocuments
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
if (returnType === 'ids' && allUpdatedDocs.length > 0) {
|
|
1359
|
+
const ids = allUpdatedDocs.map(item => item.after.id);
|
|
1360
|
+
responseData.updatedIds = ids.slice(0, maxReturn);
|
|
1361
|
+
if (ids.length > maxReturn) {
|
|
1362
|
+
responseData.truncated = true;
|
|
1363
|
+
responseData.maxReturn = maxReturn;
|
|
1364
|
+
}
|
|
1365
|
+
} else if (returnType === 'documents' && allUpdatedDocs.length > 0) {
|
|
1366
|
+
let documents = allUpdatedDocs.map(item => item.after);
|
|
1367
|
+
|
|
1368
|
+
if (options.fields) {
|
|
1369
|
+
documents = QueryEngine.selectFields(documents, options.fields);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
responseData.updatedDocuments = documents.slice(0, maxReturn);
|
|
1373
|
+
responseData.updatedDocuments = responseData.updatedDocuments;
|
|
1374
|
+
|
|
1375
|
+
if (documents.length > maxReturn) {
|
|
1376
|
+
responseData.truncated = true;
|
|
1377
|
+
responseData.maxReturn = maxReturn;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const duration = Utils.endTimer(start);
|
|
1382
|
+
const details = `Filters: ${Format.formatFilters(filters)} | Updated: ${updated} | ReturnType: ${returnType}`;
|
|
1383
|
+
Utils.logRequest('update', details, duration, Utils.getDataSize(responseData));
|
|
1384
|
+
|
|
1385
|
+
return { data: responseData };
|
|
1386
|
+
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
Utils.logError('update', err);
|
|
1389
|
+
return {
|
|
1390
|
+
error: {
|
|
1391
|
+
message: err.message || 'Failed to update documents',
|
|
1392
|
+
code: 500
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async updateById(id, { update = {}, options = {} } = {}) {
|
|
1399
|
+
const start = Utils.startTimer();
|
|
1400
|
+
try {
|
|
1401
|
+
const returnType = options.returnType || 'document';
|
|
1402
|
+
|
|
1403
|
+
const updatedDoc = await this.cache.updateDocument(
|
|
1404
|
+
this.collectionName,
|
|
1405
|
+
id,
|
|
1406
|
+
(doc) => {
|
|
1407
|
+
const normalizedUpdate = Object.keys(update).some(k => k.startsWith('$'))
|
|
1408
|
+
? update
|
|
1409
|
+
: { $set: update };
|
|
1410
|
+
|
|
1411
|
+
QueryEngine.applyUpdateToDoc(doc, normalizedUpdate);
|
|
1412
|
+
doc.updatedAt = new Date().toISOString();
|
|
1413
|
+
return doc;
|
|
1414
|
+
}
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
const responseData = {
|
|
1418
|
+
updatedCount: 1,
|
|
1419
|
+
updatedId: id
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
if (returnType === 'document') {
|
|
1423
|
+
let returnedDoc = updatedDoc;
|
|
1424
|
+
if (options.fields) {
|
|
1425
|
+
returnedDoc = QueryEngine.selectFields(updatedDoc, options.fields);
|
|
1426
|
+
}
|
|
1427
|
+
responseData.document = returnedDoc;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const duration = Utils.endTimer(start);
|
|
1431
|
+
const details = `ID: ${id} | ReturnType: ${returnType} | Updated: 1`;
|
|
1432
|
+
Utils.logRequest('update_By_Id', details, duration, Utils.getDataSize(responseData));
|
|
1433
|
+
|
|
1434
|
+
return { data: responseData };
|
|
1435
|
+
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
Utils.logError('update_By_Id', err);
|
|
1438
|
+
return {
|
|
1439
|
+
error: {
|
|
1440
|
+
message: err.message || 'Failed to update document by ID',
|
|
1441
|
+
code: 500
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async delete({ filters = {} }) {
|
|
1448
|
+
|
|
1449
|
+
if (filters === undefined || Object.keys(filters).length === 0) {
|
|
1450
|
+
throw new Error(
|
|
1451
|
+
`Filters can't be empty.` +
|
|
1452
|
+
`To clear collection, please use .drop() or dropCollection() method.`
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
}
|
|
1456
|
+
try {
|
|
1457
|
+
const start = Utils.startTimer();
|
|
1458
|
+
let deletedCount = 0;
|
|
1459
|
+
|
|
1460
|
+
await this.cache.update(this.collectionName, async (data) => {
|
|
1461
|
+
const before = data.documents.length;
|
|
1462
|
+
|
|
1463
|
+
const documentsToKeep = data.documents.filter(
|
|
1464
|
+
document => !QueryEngine.matchesFilter(document, filters)
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
deletedCount = before - documentsToKeep.length;
|
|
1468
|
+
|
|
1469
|
+
if (deletedCount > 0) {
|
|
1470
|
+
data.documents = documentsToKeep;
|
|
1471
|
+
|
|
1472
|
+
data.idIndex.clear();
|
|
1473
|
+
documentsToKeep.forEach((doc, idx) => {
|
|
1474
|
+
if (doc.id !== undefined) {
|
|
1475
|
+
data.idIndex.set(doc.id, idx);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
const duration = Utils.endTimer(start);
|
|
1482
|
+
const details = deletedCount === 0
|
|
1483
|
+
? `Filters: ${Format.formatFilters(filters)} | No documents matched`
|
|
1484
|
+
: `Filters: ${Format.formatFilters(filters)} | Deleted: ${deletedCount}`;
|
|
1485
|
+
|
|
1486
|
+
Utils.logRequest('delete', details, duration, Utils.getDataSize({ deletedCount }));
|
|
1487
|
+
|
|
1488
|
+
return {
|
|
1489
|
+
data: {
|
|
1490
|
+
collectionName: this.collectionName,
|
|
1491
|
+
deletedCount,
|
|
1492
|
+
...(deletedCount === 0 && { details: 'No documents matched filters' })
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
Utils.logError('delete', err);
|
|
1498
|
+
return {
|
|
1499
|
+
error: {
|
|
1500
|
+
message: err.message || 'Failed to delete documents',
|
|
1501
|
+
code: 500
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async deleteById(id) {
|
|
1508
|
+
const start = Utils.startTimer();
|
|
1509
|
+
try {
|
|
1510
|
+
const deleted = await this.cache.removeDocument(this.collectionName, id);
|
|
1511
|
+
|
|
1512
|
+
const duration = Utils.endTimer(start);
|
|
1513
|
+
|
|
1514
|
+
if (!deleted) {
|
|
1515
|
+
Utils.logRequest('delete_By_Id', `ID: ${id} | Not found`, duration);
|
|
1516
|
+
return {
|
|
1517
|
+
data: {
|
|
1518
|
+
deletedCount: 0,
|
|
1519
|
+
details: 'Document not found'
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
Utils.logRequest('delete_By_Id', `ID: ${id} | Deleted`, duration);
|
|
1525
|
+
|
|
1526
|
+
return {
|
|
1527
|
+
data: {
|
|
1528
|
+
deletedCount: 1,
|
|
1529
|
+
deletedId: id
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
Utils.logError('delete_By_Id', err);
|
|
1535
|
+
return {
|
|
1536
|
+
error: {
|
|
1537
|
+
message: err.message || 'Failed to delete document by ID',
|
|
1538
|
+
code: 500
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async dropCollection() {
|
|
1545
|
+
try {
|
|
1546
|
+
const start = Utils.startTimer();
|
|
1547
|
+
|
|
1548
|
+
const data = this.cache.cache.get(this.collectionName);
|
|
1549
|
+
if (data) {
|
|
1550
|
+
if (data.dirty) {
|
|
1551
|
+
await this.cache.save(this.collectionName);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
this.cache.cache.delete(this.collectionName);
|
|
1555
|
+
this.cache.dirty.delete(this.collectionName);
|
|
1556
|
+
this.cache.locks.delete(this.collectionName);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const filePath = path.join(this.storagePath, `${this.collectionName}.json`);
|
|
1560
|
+
try {
|
|
1561
|
+
await fs.unlink(filePath);
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
if (err.code !== 'ENOENT') {
|
|
1564
|
+
throw err;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const duration = Utils.endTimer(start);
|
|
1569
|
+
Utils.logRequest('dropCollection', 'Success', duration);
|
|
1570
|
+
|
|
1571
|
+
return {
|
|
1572
|
+
data: {
|
|
1573
|
+
collectionName: this.collectionName,
|
|
1574
|
+
dropped: true
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
Utils.logError('dropCollection', err);
|
|
1580
|
+
return {
|
|
1581
|
+
error: {
|
|
1582
|
+
message: err.message || 'Unexpected error during dropCollection',
|
|
1583
|
+
code: 500
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async close() {
|
|
1590
|
+
await this.cache.flushAll();
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
class Collection {
|
|
1596
|
+
constructor(adapter, collectionName) {
|
|
1597
|
+
this.adapter = adapter;
|
|
1598
|
+
this.collectionName = collectionName;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async count(filters = {}) {
|
|
1602
|
+
Validator.validateFilters(filters);
|
|
1603
|
+
|
|
1604
|
+
return this.adapter.request('GET', `/collections/${encodeURIComponent(this.collectionName)}/count`, {
|
|
1605
|
+
filters
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async find(filters = {}, options = {}) {
|
|
1610
|
+
Validator.validateFilters(filters);
|
|
1611
|
+
Validator.validateOptions(options);
|
|
1612
|
+
|
|
1613
|
+
return this.adapter.request('GET', `/collections/${encodeURIComponent(this.collectionName)}`, {
|
|
1614
|
+
filters,
|
|
1615
|
+
options
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async findById(id, options = {}) {
|
|
1620
|
+
Validator.validateOptions(options);
|
|
1621
|
+
|
|
1622
|
+
return this.adapter.request('GET', `/collections/${encodeURIComponent(this.collectionName)}/${id}`, {
|
|
1623
|
+
options
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
async findOne(filters = {}, options = {}) {
|
|
1628
|
+
Validator.validateFilters(filters);
|
|
1629
|
+
Validator.validateOptions(options);
|
|
1630
|
+
|
|
1631
|
+
try {
|
|
1632
|
+
const { error, data } = await this.adapter.request('GET', `/collections/${encodeURIComponent(this.collectionName)}`, {
|
|
1633
|
+
filters,
|
|
1634
|
+
options: { limit: 1, sort: { updatedAt: -1 }, ...options }
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
if (error) return { error };
|
|
1638
|
+
|
|
1639
|
+
if (data.foundDocuments.length > 0) {
|
|
1640
|
+
return {
|
|
1641
|
+
data: data.foundDocuments[0]
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return {
|
|
1646
|
+
data: null
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
console.error(err);
|
|
1651
|
+
return {
|
|
1652
|
+
error: {
|
|
1653
|
+
message: err.message || 'Failed to find one document',
|
|
1654
|
+
code: 500
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async insert(documents) {
|
|
1661
|
+
return this.adapter.request('POST', `/collections/${encodeURIComponent(this.collectionName)}`, {
|
|
1662
|
+
documents
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async update(filters = {}, update = {}, options = {}) {
|
|
1667
|
+
Validator.validateFilters(filters);
|
|
1668
|
+
Validator.validateOptions(options);
|
|
1669
|
+
|
|
1670
|
+
return this.adapter.request('PATCH', `/collections/${encodeURIComponent(this.collectionName)}`, {
|
|
1671
|
+
filters,
|
|
1672
|
+
update,
|
|
1673
|
+
options
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async updateById(id, update = {}, options = {}) {
|
|
1678
|
+
Validator.validateOptions(options);
|
|
1679
|
+
|
|
1680
|
+
return this.adapter.request('PATCH', `/collections/${encodeURIComponent(this.collectionName)}/${id}`, {
|
|
1681
|
+
update,
|
|
1682
|
+
options
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async delete(filters) {
|
|
1687
|
+
if (!filters) {
|
|
1688
|
+
throw new Error('Delete operation requires filters to prevent accidental deletion of all documents. {} or use .drop() to delete entire collection.');
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
Validator.validateFilters(filters);
|
|
1692
|
+
|
|
1693
|
+
return this.adapter.request('DELETE', `/collections/${encodeURIComponent(this.collectionName)}`, {
|
|
1694
|
+
filters
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async deleteById(id) {
|
|
1699
|
+
if (!id) {
|
|
1700
|
+
throw new Error('deleteById operation requires a valid document ID.');
|
|
1701
|
+
}
|
|
1702
|
+
return this.adapter.request('DELETE', `/collections/${encodeURIComponent(this.collectionName)}/${id}`);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async drop() {
|
|
1706
|
+
return this.adapter.request('DELETE', `/collections/${encodeURIComponent(this.collectionName)}/drop`);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
class LiekoDB {
|
|
1711
|
+
constructor(options = {}) {
|
|
1712
|
+
this.debug = options.debug || false;
|
|
1713
|
+
|
|
1714
|
+
const isBrowser =
|
|
1715
|
+
typeof window !== 'undefined' &&
|
|
1716
|
+
typeof window.document !== 'undefined';
|
|
1717
|
+
|
|
1718
|
+
if (isBrowser && !options.token) {
|
|
1719
|
+
throw new Error(
|
|
1720
|
+
'LiekoDB: A token is required when used in a browser environment. ' +
|
|
1721
|
+
'LocalAdapter is only available in Node.js.'
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
this.adapter = this._createAdapter(options);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
_createAdapter(options) {
|
|
1728
|
+
if (options.token) {
|
|
1729
|
+
return new HTTPAdapter(options);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
1733
|
+
return new LocalAdapter(options);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
throw new Error(
|
|
1737
|
+
'LiekoDB: LocalAdapter cannot be used outside of Node.js'
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
collection(name) {
|
|
1742
|
+
Validator.validateCollectionName(name);
|
|
1743
|
+
return new Collection(this.adapter, name);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async listCollections() {
|
|
1747
|
+
return this.adapter.listCollections();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async dropCollection(name) {
|
|
1751
|
+
Validator.validateCollectionName(name);
|
|
1752
|
+
return this.adapter.dropCollection(name);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async status() {
|
|
1756
|
+
return this.adapter.status();
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
async close() {
|
|
1760
|
+
return this.adapter.close();
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
class Format {
|
|
1765
|
+
|
|
1766
|
+
static formatBytes(bytes) {
|
|
1767
|
+
if (bytes === 0) return '0 B';
|
|
1768
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1769
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
|
1770
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
static formatDuration(ms) {
|
|
1774
|
+
if (ms < 0.001) return `${(ms * 1000).toFixed(2)} µs`;
|
|
1775
|
+
if (ms < 1) return `${(ms * 1000).toFixed(0)} µs`;
|
|
1776
|
+
if (ms < 1000) return `${ms.toFixed(2)} ms`;
|
|
1777
|
+
return `${(ms / 1000).toFixed(2)} s`;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* TODO: when filter is regex, log is only {}
|
|
1782
|
+
* {{ email: { '$regex': /@google\.com$/ } }
|
|
1783
|
+
* [LiekoDB] FIND users | Duration: 101µs | Response Size: 175 B | Filters: {email:{$regex:{}}} | Found: 1}
|
|
1784
|
+
*/
|
|
1785
|
+
static formatFilters(filters) {
|
|
1786
|
+
if (!filters || Object.keys(filters).length === 0) return '{}';
|
|
1787
|
+
const formatted = JSON.stringify(filters, null, 0)
|
|
1788
|
+
.replace(/"/g, '')
|
|
1789
|
+
.replace(/,/g, ', ');
|
|
1790
|
+
if (formatted.length > 80) {
|
|
1791
|
+
return formatted.substring(0, 77) + '...';
|
|
1792
|
+
}
|
|
1793
|
+
return formatted;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
static formatOptions(options) {
|
|
1797
|
+
if (!options || Object.keys(options).length === 0) return '';
|
|
1798
|
+
const parts = [];
|
|
1799
|
+
if (options.sort) parts.push(`sort: ${JSON.stringify(options.sort).replace(/"/g, '')}`);
|
|
1800
|
+
if (options.limit) parts.push(`limit: ${options.limit}`);
|
|
1801
|
+
if (options.skip) parts.push(`skip: ${options.skip}`);
|
|
1802
|
+
if (options.fields) parts.push(`fields: ${JSON.stringify(options.fields).replace(/"/g, '')}`);
|
|
1803
|
+
return parts.length > 0 ? ` | ${parts.join(', ')}` : '';
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
static formatFieldsLog(fields) {
|
|
1807
|
+
if (!fields || typeof fields !== 'object') return '';
|
|
1808
|
+
|
|
1809
|
+
const formatted = Object.entries(fields)
|
|
1810
|
+
.map(([key, value]) => {
|
|
1811
|
+
if (value === 1 || value === true) return `+${key}`;
|
|
1812
|
+
if (value === -1 || value === false) return `-${key}`;
|
|
1813
|
+
return `${key}:${value}`;
|
|
1814
|
+
})
|
|
1815
|
+
.join(', ');
|
|
1816
|
+
|
|
1817
|
+
return `Fields: {${formatted}}`;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
class Validator {
|
|
1822
|
+
|
|
1823
|
+
static validateCollectionName(name) {
|
|
1824
|
+
if (!name || typeof name !== 'string') {
|
|
1825
|
+
throw new Error(`Collection name must be a non-empty string, got: ${typeof name}`);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (name.length < 1) {
|
|
1829
|
+
throw new Error('Collection name cannot be empty');
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
if (name.length > 64) {
|
|
1833
|
+
throw new Error(`Collection name too long (${name.length} > 64 characters)`);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const validNameRegex = /^[a-zA-Z0-9_-]+$/;
|
|
1837
|
+
if (!validNameRegex.test(name)) {
|
|
1838
|
+
throw new Error(
|
|
1839
|
+
`Invalid collection name: "${name}". ` +
|
|
1840
|
+
`Only alphanumeric characters, underscores (_) and hyphens (-) are allowed.`
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (/^[0-9_-]/.test(name)) {
|
|
1845
|
+
throw new Error('Collection name cannot start with a number, underscore or hyphen');
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const invalidPatterns = [
|
|
1849
|
+
/\.\./,
|
|
1850
|
+
/\/|\\/,
|
|
1851
|
+
/^\./,
|
|
1852
|
+
/\s/,
|
|
1853
|
+
/[<>:"|?*]/,
|
|
1854
|
+
];
|
|
1855
|
+
|
|
1856
|
+
for (const pattern of invalidPatterns) {
|
|
1857
|
+
if (pattern.test(name)) {
|
|
1858
|
+
throw new Error(`Collection name contains invalid characters: "${name}"`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
static validateFilters(filters) {
|
|
1866
|
+
if (filters == null || typeof filters !== 'object' || Array.isArray(filters)) {
|
|
1867
|
+
throw new Error('Filters must be a non-null plain object');
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const validOperators = ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin',
|
|
1871
|
+
'$exists', '$regex', '$and', '$or', '$not'];
|
|
1872
|
+
|
|
1873
|
+
for (const key in filters) {
|
|
1874
|
+
if (key.startsWith('$') && !validOperators.includes(key)) {
|
|
1875
|
+
throw new Error(`Invalid query operator: >> ${key} << \nValid operators: ${validOperators.join(', ')}`);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const value = filters[key];
|
|
1879
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1880
|
+
this.validateFilters(value);
|
|
1881
|
+
} else if (Array.isArray(value) && (key === '$and' || key === '$or' || key === '$in' || key === '$nin')) {
|
|
1882
|
+
value.forEach(item => {
|
|
1883
|
+
if (typeof item === 'object' && item !== null) {
|
|
1884
|
+
this.validateFilters(item);
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
static validateOptions(options) {
|
|
1892
|
+
if (options == null || typeof options !== 'object' || Array.isArray(options)) {
|
|
1893
|
+
throw new Error('Options must be a non-null plain object');
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const validKeys = ['sort', 'skip', 'limit', 'fields', 'page', 'returnType', 'maxReturn'];
|
|
1897
|
+
|
|
1898
|
+
for (const key in options) {
|
|
1899
|
+
if (!validKeys.includes(key)) {
|
|
1900
|
+
throw new Error(`Invalid option key: >> ${key} << \nValid keys: ${validKeys.join(', ')}`);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (key === 'sort') {
|
|
1904
|
+
if (typeof options[key] !== 'object' || options[key] == null || Array.isArray(options[key])) {
|
|
1905
|
+
throw new Error('Sort must be a non-null plain object');
|
|
1906
|
+
}
|
|
1907
|
+
for (const sortKey in options[key]) {
|
|
1908
|
+
const val = options[key][sortKey];
|
|
1909
|
+
if (![1, -1, true, false].includes(val)) {
|
|
1910
|
+
throw new Error(`Invalid sort value for ${sortKey}: ${val}. Must be 1, -1, true, or false`);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
} else if (key === 'skip') {
|
|
1914
|
+
if (typeof options[key] !== 'number' || options[key] < 0) {
|
|
1915
|
+
throw new Error('Skip must be a non-negative number');
|
|
1916
|
+
}
|
|
1917
|
+
} else if (key === 'limit') {
|
|
1918
|
+
if (typeof options[key] === 'number') {
|
|
1919
|
+
if (options[key] < 0) {
|
|
1920
|
+
throw new Error('Limit must be a non-negative number');
|
|
1921
|
+
}
|
|
1922
|
+
} else {
|
|
1923
|
+
throw new Error('Limit must be a non-negative number');
|
|
1924
|
+
}
|
|
1925
|
+
} else if (key === 'fields') {
|
|
1926
|
+
if (typeof options[key] !== 'object' || options[key] == null || Array.isArray(options[key])) {
|
|
1927
|
+
throw new Error('Fields must be a non-null plain object');
|
|
1928
|
+
}
|
|
1929
|
+
for (const fieldKey in options[key]) {
|
|
1930
|
+
const val = options[key][fieldKey];
|
|
1931
|
+
if (![1, -1, true, false].includes(val)) {
|
|
1932
|
+
throw new Error(`Invalid field value for ${fieldKey}: ${val}. Must be 1, -1, true, or false`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
} else if (key === 'page') {
|
|
1936
|
+
if (typeof options[key] !== 'number' || options[key] <= 0) {
|
|
1937
|
+
throw new Error('Page must be a positive number');
|
|
1938
|
+
}
|
|
1939
|
+
} else if (key === 'returnType') {
|
|
1940
|
+
const validTypes = ['count', 'ids', 'documents'];
|
|
1941
|
+
if (!validTypes.includes(options[key])) {
|
|
1942
|
+
throw new Error(
|
|
1943
|
+
`Invalid returnType: "${options[key]}". ` +
|
|
1944
|
+
`Must be one of: ${validTypes.join(', ')}`
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
} else if (key === 'maxReturn') {
|
|
1948
|
+
if (typeof options[key] !== 'number' || options[key] < 0 || !Number.isInteger(options[key])) {
|
|
1949
|
+
throw new Error('maxReturn must be a non-negative integer');
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
class Utils {
|
|
1957
|
+
static startTimer() {
|
|
1958
|
+
return process.hrtime.bigint();
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
static endTimer(start) {
|
|
1962
|
+
const end = process.hrtime.bigint();
|
|
1963
|
+
const diffNs = end - start;
|
|
1964
|
+
return Number(diffNs) / 1_000_000; // ms
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
static generateId() {
|
|
1968
|
+
return require('crypto').randomBytes(8).toString('hex');
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
static log(...args) {
|
|
1972
|
+
if (this.debug) console.log('[LiekoDB]', ...args);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
static logRequest(operation, details, duration, responseSize) {
|
|
1976
|
+
if (!this.debug) return;
|
|
1977
|
+
|
|
1978
|
+
const durationFormatted = Format.formatDuration(duration);
|
|
1979
|
+
|
|
1980
|
+
let sizePart = '';
|
|
1981
|
+
if (typeof responseSize === 'number' && Number.isFinite(responseSize) && responseSize > 0) {
|
|
1982
|
+
sizePart = ` | Response Size: ${Format.formatBytes(responseSize)}`;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
this.log(
|
|
1986
|
+
`${operation.toUpperCase()} | Collection: ${this.collectionName} | ` +
|
|
1987
|
+
`Duration: ${durationFormatted}` +
|
|
1988
|
+
sizePart +
|
|
1989
|
+
(details ? ` | ${details}` : '')
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
static logError(operation, err) {
|
|
1994
|
+
console.error(
|
|
1995
|
+
`[LiekoDB] ERROR - ${operation.toUpperCase()} | Collection: ${this.collectionName}` +
|
|
1996
|
+
(err ? ` | ${err}` : '')
|
|
1997
|
+
);
|
|
1998
|
+
console.error(err)
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
static getDataSize(data) {
|
|
2002
|
+
try {
|
|
2003
|
+
return Buffer.byteLength(JSON.stringify(data), 'utf8');
|
|
2004
|
+
} catch (e) {
|
|
2005
|
+
return 0;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
module.exports = LiekoDB;
|
|
2011
|
+
|
|
2012
|
+
if (typeof window !== 'undefined') {
|
|
2013
|
+
window.LiekoDB = LiekoDB;
|
|
2014
|
+
}
|