jexidb 2.0.2 → 2.1.0
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/.babelrc +13 -0
- package/.gitattributes +2 -0
- package/CHANGELOG.md +140 -0
- package/LICENSE +21 -21
- package/README.md +301 -527
- package/babel.config.json +5 -0
- package/dist/Database.cjs +3896 -0
- package/docs/API.md +1051 -0
- package/docs/EXAMPLES.md +701 -0
- package/docs/README.md +194 -0
- package/examples/iterate-usage-example.js +157 -0
- package/examples/simple-iterate-example.js +115 -0
- package/jest.config.js +24 -0
- package/package.json +63 -51
- package/scripts/README.md +47 -0
- package/scripts/clean-test-files.js +75 -0
- package/scripts/prepare.js +31 -0
- package/scripts/run-tests.js +80 -0
- package/src/Database.mjs +4130 -0
- package/src/FileHandler.mjs +1101 -0
- package/src/OperationQueue.mjs +279 -0
- package/src/SchemaManager.mjs +268 -0
- package/src/Serializer.mjs +511 -0
- package/src/managers/ConcurrencyManager.mjs +257 -0
- package/src/managers/IndexManager.mjs +1403 -0
- package/src/managers/QueryManager.mjs +1273 -0
- package/src/managers/StatisticsManager.mjs +262 -0
- package/src/managers/StreamingProcessor.mjs +429 -0
- package/src/managers/TermManager.mjs +278 -0
- package/test/$not-operator-with-and.test.js +282 -0
- package/test/README.md +8 -0
- package/test/close-init-cycle.test.js +256 -0
- package/test/critical-bugs-fixes.test.js +1069 -0
- package/test/index-persistence.test.js +306 -0
- package/test/index-serialization.test.js +314 -0
- package/test/indexed-query-mode.test.js +360 -0
- package/test/iterate-method.test.js +272 -0
- package/test/query-operators.test.js +238 -0
- package/test/regex-array-fields.test.js +129 -0
- package/test/score-method.test.js +238 -0
- package/test/setup.js +17 -0
- package/test/term-mapping-minimal.test.js +154 -0
- package/test/term-mapping-simple.test.js +257 -0
- package/test/term-mapping.test.js +514 -0
- package/test/writebuffer-flush-resilience.test.js +204 -0
- package/dist/FileHandler.js +0 -688
- package/dist/IndexManager.js +0 -353
- package/dist/IntegrityChecker.js +0 -364
- package/dist/JSONLDatabase.js +0 -1194
- package/dist/index.js +0 -617
- package/src/FileHandler.js +0 -674
- package/src/IndexManager.js +0 -363
- package/src/IntegrityChecker.js +0 -379
- package/src/JSONLDatabase.js +0 -1248
- package/src/index.js +0 -608
|
@@ -0,0 +1,3896 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.default = exports.Database = void 0;
|
|
7
|
+
var _events = require("events");
|
|
8
|
+
var _IndexManager = _interopRequireDefault(require("./managers/IndexManager.mjs"));
|
|
9
|
+
var _Serializer = _interopRequireDefault(require("./Serializer.mjs"));
|
|
10
|
+
var _asyncMutex = require("async-mutex");
|
|
11
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
12
|
+
var _readline = _interopRequireDefault(require("readline"));
|
|
13
|
+
var _OperationQueue = require("./OperationQueue.mjs");
|
|
14
|
+
var _FileHandler = _interopRequireDefault(require("./FileHandler.mjs"));
|
|
15
|
+
var _QueryManager = require("./managers/QueryManager.mjs");
|
|
16
|
+
var _ConcurrencyManager = require("./managers/ConcurrencyManager.mjs");
|
|
17
|
+
var _StatisticsManager = require("./managers/StatisticsManager.mjs");
|
|
18
|
+
var _StreamingProcessor = _interopRequireDefault(require("./managers/StreamingProcessor.mjs"));
|
|
19
|
+
var _TermManager = _interopRequireDefault(require("./managers/TermManager.mjs"));
|
|
20
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
21
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
22
|
+
function _awaitAsyncGenerator(e) { return new _OverloadYield(e, 0); }
|
|
23
|
+
function _wrapAsyncGenerator(e) { return function () { return new AsyncGenerator(e.apply(this, arguments)); }; }
|
|
24
|
+
function AsyncGenerator(e) { var r, t; function resume(r, t) { try { var n = e[r](t), o = n.value, u = o instanceof _OverloadYield; Promise.resolve(u ? o.v : o).then(function (t) { if (u) { var i = "return" === r ? "return" : "next"; if (!o.k || t.done) return resume(i, t); t = e[i](t).value; } settle(n.done ? "return" : "normal", t); }, function (e) { resume("throw", e); }); } catch (e) { settle("throw", e); } } function settle(e, n) { switch (e) { case "return": r.resolve({ value: n, done: !0 }); break; case "throw": r.reject(n); break; default: r.resolve({ value: n, done: !1 }); } (r = r.next) ? resume(r.key, r.arg) : t = null; } this._invoke = function (e, n) { return new Promise(function (o, u) { var i = { key: e, arg: n, resolve: o, reject: u, next: null }; t ? t = t.next = i : (r = t = i, resume(e, n)); }); }, "function" != typeof e.return && (this.return = void 0); }
|
|
25
|
+
AsyncGenerator.prototype["function" == typeof Symbol && Symbol.asyncIterator || "@@asyncIterator"] = function () { return this; }, AsyncGenerator.prototype.next = function (e) { return this._invoke("next", e); }, AsyncGenerator.prototype.throw = function (e) { return this._invoke("throw", e); }, AsyncGenerator.prototype.return = function (e) { return this._invoke("return", e); };
|
|
26
|
+
function _OverloadYield(e, d) { this.v = e, this.k = d; }
|
|
27
|
+
function _asyncIterator(r) { var n, t, o, e = 2; for ("undefined" != typeof Symbol && (t = Symbol.asyncIterator, o = Symbol.iterator); e--;) { if (t && null != (n = r[t])) return n.call(r); if (o && null != (n = r[o])) return new AsyncFromSyncIterator(n.call(r)); t = "@@asyncIterator", o = "@@iterator"; } throw new TypeError("Object is not async iterable"); }
|
|
28
|
+
function AsyncFromSyncIterator(r) { function AsyncFromSyncIteratorContinuation(r) { if (Object(r) !== r) return Promise.reject(new TypeError(r + " is not an object.")); var n = r.done; return Promise.resolve(r.value).then(function (r) { return { value: r, done: n }; }); } return AsyncFromSyncIterator = function (r) { this.s = r, this.n = r.next; }, AsyncFromSyncIterator.prototype = { s: null, n: null, next: function () { return AsyncFromSyncIteratorContinuation(this.n.apply(this.s, arguments)); }, return: function (r) { var n = this.s.return; return void 0 === n ? Promise.resolve({ value: r, done: !0 }) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); }, throw: function (r) { var n = this.s.return; return void 0 === n ? Promise.reject(r) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); } }, new AsyncFromSyncIterator(r); }
|
|
29
|
+
/**
|
|
30
|
+
* IterateEntry class for intuitive API with automatic change detection
|
|
31
|
+
* Uses native JavaScript setters for maximum performance
|
|
32
|
+
*/
|
|
33
|
+
class IterateEntry {
|
|
34
|
+
constructor(entry, originalRecord) {
|
|
35
|
+
this._entry = entry;
|
|
36
|
+
this._originalRecord = originalRecord;
|
|
37
|
+
this._modified = false;
|
|
38
|
+
this._markedForDeletion = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generic getter that returns values from the original entry
|
|
42
|
+
get(property) {
|
|
43
|
+
return this._entry[property];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Generic setter that sets values in the original entry
|
|
47
|
+
set(property, value) {
|
|
48
|
+
this._entry[property] = value;
|
|
49
|
+
this._modified = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Delete method for intuitive deletion
|
|
53
|
+
delete() {
|
|
54
|
+
this._markedForDeletion = true;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Getter for the underlying entry (for compatibility)
|
|
59
|
+
get value() {
|
|
60
|
+
return this._entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if entry was modified
|
|
64
|
+
get isModified() {
|
|
65
|
+
return this._modified;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if entry is marked for deletion
|
|
69
|
+
get isMarkedForDeletion() {
|
|
70
|
+
return this._markedForDeletion;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Proxy all property access to the underlying entry
|
|
74
|
+
get [Symbol.toPrimitive]() {
|
|
75
|
+
return this._entry;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Handle property access dynamically
|
|
79
|
+
get [Symbol.toStringTag]() {
|
|
80
|
+
return 'IterateEntry';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Import managers
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* InsertSession - Simple batch insertion without memory duplication
|
|
88
|
+
*/
|
|
89
|
+
class InsertSession {
|
|
90
|
+
constructor(database, sessionOptions = {}) {
|
|
91
|
+
this.database = database;
|
|
92
|
+
this.batchSize = sessionOptions.batchSize || 100;
|
|
93
|
+
this.totalInserted = 0;
|
|
94
|
+
this.flushing = false;
|
|
95
|
+
this.batches = []; // Array of batches to avoid slice() in flush()
|
|
96
|
+
this.currentBatch = []; // Current batch being filled
|
|
97
|
+
this.sessionId = Math.random().toString(36).substr(2, 9);
|
|
98
|
+
|
|
99
|
+
// Register this session as active
|
|
100
|
+
this.database.activeInsertSessions.add(this);
|
|
101
|
+
}
|
|
102
|
+
async add(record) {
|
|
103
|
+
// CRITICAL FIX: Remove the committed check to allow auto-reusability
|
|
104
|
+
// The session should be able to handle multiple commits
|
|
105
|
+
|
|
106
|
+
if (this.database.destroyed) {
|
|
107
|
+
throw new Error('Database is destroyed');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Process record
|
|
111
|
+
const finalRecord = {
|
|
112
|
+
...record
|
|
113
|
+
};
|
|
114
|
+
const id = finalRecord.id || this.database.generateId();
|
|
115
|
+
finalRecord.id = id;
|
|
116
|
+
|
|
117
|
+
// Add to current batch
|
|
118
|
+
this.currentBatch.push(finalRecord);
|
|
119
|
+
this.totalInserted++;
|
|
120
|
+
|
|
121
|
+
// If batch is full, move it to batches array
|
|
122
|
+
if (this.currentBatch.length >= this.batchSize) {
|
|
123
|
+
this.batches.push(this.currentBatch);
|
|
124
|
+
this.currentBatch = [];
|
|
125
|
+
}
|
|
126
|
+
return finalRecord;
|
|
127
|
+
}
|
|
128
|
+
async flush() {
|
|
129
|
+
// Check if there's anything to flush
|
|
130
|
+
if (this.batches.length === 0 && this.currentBatch.length === 0) return;
|
|
131
|
+
|
|
132
|
+
// Prevent concurrent flushes
|
|
133
|
+
if (this.flushing) return;
|
|
134
|
+
this.flushing = true;
|
|
135
|
+
try {
|
|
136
|
+
// Process all complete batches
|
|
137
|
+
for (const batch of this.batches) {
|
|
138
|
+
await this.database.insertBatch(batch);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Process remaining records in current batch
|
|
142
|
+
if (this.currentBatch.length > 0) {
|
|
143
|
+
await this.database.insertBatch(this.currentBatch);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Clear all batches
|
|
147
|
+
this.batches = [];
|
|
148
|
+
this.currentBatch = [];
|
|
149
|
+
} finally {
|
|
150
|
+
this.flushing = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async commit() {
|
|
154
|
+
// CRITICAL FIX: Make session auto-reusable by removing committed state
|
|
155
|
+
// Allow multiple commits on the same session
|
|
156
|
+
|
|
157
|
+
await this.flush();
|
|
158
|
+
|
|
159
|
+
// Reset session state for next commit cycle
|
|
160
|
+
const insertedCount = this.totalInserted;
|
|
161
|
+
this.totalInserted = 0;
|
|
162
|
+
return insertedCount;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wait for this session's operations to complete
|
|
167
|
+
*/
|
|
168
|
+
async waitForOperations(maxWaitTime = null) {
|
|
169
|
+
const startTime = Date.now();
|
|
170
|
+
const hasTimeout = maxWaitTime !== null && maxWaitTime !== undefined;
|
|
171
|
+
while (this.flushing || this.batches.length > 0 || this.currentBatch.length > 0) {
|
|
172
|
+
// Check timeout only if we have one
|
|
173
|
+
if (hasTimeout && Date.now() - startTime >= maxWaitTime) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if this session has pending operations
|
|
183
|
+
*/
|
|
184
|
+
hasPendingOperations() {
|
|
185
|
+
return this.flushing || this.batches.length > 0 || this.currentBatch.length > 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Destroy this session and unregister it
|
|
190
|
+
*/
|
|
191
|
+
destroy() {
|
|
192
|
+
// Unregister from database
|
|
193
|
+
this.database.activeInsertSessions.delete(this);
|
|
194
|
+
|
|
195
|
+
// Clear all data
|
|
196
|
+
this.batches = [];
|
|
197
|
+
this.currentBatch = [];
|
|
198
|
+
this.totalInserted = 0;
|
|
199
|
+
this.flushing = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* JexiDB - A high-performance, in-memory database with persistence
|
|
205
|
+
*
|
|
206
|
+
* Features:
|
|
207
|
+
* - In-memory storage with optional persistence
|
|
208
|
+
* - Advanced indexing and querying
|
|
209
|
+
* - Transaction support
|
|
210
|
+
* - Manual save functionality
|
|
211
|
+
* - Recovery mechanisms
|
|
212
|
+
* - Performance optimizations
|
|
213
|
+
*/
|
|
214
|
+
class Database extends _events.EventEmitter {
|
|
215
|
+
constructor(file, opts = {}) {
|
|
216
|
+
super();
|
|
217
|
+
|
|
218
|
+
// Generate unique instance ID for debugging
|
|
219
|
+
this.instanceId = Math.random().toString(36).substr(2, 9);
|
|
220
|
+
|
|
221
|
+
// Initialize state flags
|
|
222
|
+
this.managersInitialized = false;
|
|
223
|
+
|
|
224
|
+
// Track active insert sessions
|
|
225
|
+
this.activeInsertSessions = new Set();
|
|
226
|
+
|
|
227
|
+
// Set default options
|
|
228
|
+
this.opts = Object.assign({
|
|
229
|
+
// Core options - auto-save removed, user must call save() manually
|
|
230
|
+
// File creation options
|
|
231
|
+
create: opts.create !== false,
|
|
232
|
+
// Create file if it doesn't exist (default true)
|
|
233
|
+
clear: opts.clear === true,
|
|
234
|
+
// Clear existing files before loading (default false)
|
|
235
|
+
// Timeout configurations for preventing hangs
|
|
236
|
+
mutexTimeout: opts.mutexTimeout || 15000,
|
|
237
|
+
// 15 seconds timeout for mutex operations
|
|
238
|
+
maxFlushAttempts: opts.maxFlushAttempts || 50,
|
|
239
|
+
// Maximum flush attempts before giving up
|
|
240
|
+
// Term mapping options (always enabled and auto-detected from indexes)
|
|
241
|
+
termMappingCleanup: opts.termMappingCleanup !== false,
|
|
242
|
+
// Clean up orphaned terms on save (enabled by default)
|
|
243
|
+
// Recovery options
|
|
244
|
+
enableRecovery: opts.enableRecovery === true,
|
|
245
|
+
// Recovery mechanisms disabled by default for large databases
|
|
246
|
+
// Buffer size options for range merging
|
|
247
|
+
maxBufferSize: opts.maxBufferSize || 4 * 1024 * 1024,
|
|
248
|
+
// 4MB default maximum buffer size for grouped ranges
|
|
249
|
+
// Memory management options (similar to published v1.1.0)
|
|
250
|
+
maxMemoryUsage: opts.maxMemoryUsage || 64 * 1024,
|
|
251
|
+
// 64KB limit like published version
|
|
252
|
+
maxWriteBufferSize: opts.maxWriteBufferSize || 1000,
|
|
253
|
+
// Maximum records in writeBuffer
|
|
254
|
+
// Query strategy options
|
|
255
|
+
streamingThreshold: opts.streamingThreshold || 0.8,
|
|
256
|
+
// Use streaming when limit > 80% of total records
|
|
257
|
+
// Serialization options
|
|
258
|
+
enableArraySerialization: opts.enableArraySerialization !== false // Enable array serialization by default
|
|
259
|
+
}, opts);
|
|
260
|
+
|
|
261
|
+
// CRITICAL FIX: Initialize AbortController for lifecycle management
|
|
262
|
+
this.abortController = new AbortController();
|
|
263
|
+
this.pendingOperations = new Set();
|
|
264
|
+
this.pendingPromises = new Set();
|
|
265
|
+
this.destroyed = false;
|
|
266
|
+
this.destroying = false;
|
|
267
|
+
this.closed = false;
|
|
268
|
+
this.operationCounter = 0;
|
|
269
|
+
|
|
270
|
+
// CRITICAL FIX: Initialize OperationQueue to prevent race conditions
|
|
271
|
+
this.operationQueue = new _OperationQueue.OperationQueue(false); // Disable debug mode for queue
|
|
272
|
+
|
|
273
|
+
// Normalize file path to ensure it ends with .jdb
|
|
274
|
+
this.normalizedFile = this.normalizeFilePath(file);
|
|
275
|
+
|
|
276
|
+
// Initialize core properties
|
|
277
|
+
this.offsets = []; // Array of byte offsets for each record
|
|
278
|
+
this.indexOffset = 0; // Current position in file for new records
|
|
279
|
+
this.deletedIds = new Set(); // Track deleted record IDs
|
|
280
|
+
this.shouldSave = false;
|
|
281
|
+
this.isLoading = false;
|
|
282
|
+
this.isSaving = false;
|
|
283
|
+
this.lastSaveTime = null;
|
|
284
|
+
this.initialized = false;
|
|
285
|
+
|
|
286
|
+
// Initialize managers
|
|
287
|
+
this.initializeManagers();
|
|
288
|
+
|
|
289
|
+
// Initialize file mutex for thread safety
|
|
290
|
+
this.fileMutex = new _asyncMutex.Mutex();
|
|
291
|
+
|
|
292
|
+
// Initialize performance tracking
|
|
293
|
+
this.performanceStats = {
|
|
294
|
+
operations: 0,
|
|
295
|
+
saves: 0,
|
|
296
|
+
loads: 0,
|
|
297
|
+
queryTime: 0,
|
|
298
|
+
saveTime: 0,
|
|
299
|
+
loadTime: 0
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Initialize usage stats for QueryManager
|
|
303
|
+
this.usageStats = {
|
|
304
|
+
totalQueries: 0,
|
|
305
|
+
indexedQueries: 0,
|
|
306
|
+
streamingQueries: 0,
|
|
307
|
+
indexedAverageTime: 0,
|
|
308
|
+
streamingAverageTime: 0
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Note: Validation will be done after configuration conversion in initializeManagers()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validate field and index configuration
|
|
316
|
+
*/
|
|
317
|
+
validateIndexConfiguration() {
|
|
318
|
+
// Validate fields configuration
|
|
319
|
+
if (this.opts.fields && typeof this.opts.fields === 'object') {
|
|
320
|
+
this.validateFieldTypes(this.opts.fields, 'fields');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate indexes configuration (legacy support)
|
|
324
|
+
if (this.opts.indexes && typeof this.opts.indexes === 'object') {
|
|
325
|
+
this.validateFieldTypes(this.opts.indexes, 'indexes');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Validate indexes array (new format) - but only if we have fields
|
|
329
|
+
if (this.opts.originalIndexes && Array.isArray(this.opts.originalIndexes)) {
|
|
330
|
+
if (!this.opts.fields) {
|
|
331
|
+
throw new Error('Index fields array requires fields configuration. Use: { fields: {...}, indexes: [...] }');
|
|
332
|
+
}
|
|
333
|
+
this.validateIndexFields(this.opts.originalIndexes);
|
|
334
|
+
}
|
|
335
|
+
if (this.opts.debugMode) {
|
|
336
|
+
const fieldCount = this.opts.fields ? Object.keys(this.opts.fields).length : 0;
|
|
337
|
+
const indexCount = Array.isArray(this.opts.indexes) ? this.opts.indexes.length : this.opts.indexes && typeof this.opts.indexes === 'object' ? Object.keys(this.opts.indexes).length : 0;
|
|
338
|
+
if (fieldCount > 0 || indexCount > 0) {
|
|
339
|
+
console.log(`✅ Configuration validated: ${fieldCount} fields, ${indexCount} indexes [${this.instanceId}]`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Validate field types
|
|
346
|
+
*/
|
|
347
|
+
validateFieldTypes(fields, configType) {
|
|
348
|
+
const supportedTypes = ['string', 'number', 'boolean', 'array:string', 'array:number', 'array:boolean', 'array', 'object'];
|
|
349
|
+
const errors = [];
|
|
350
|
+
for (const [fieldName, fieldType] of Object.entries(fields)) {
|
|
351
|
+
// Check if type is supported
|
|
352
|
+
if (!supportedTypes.includes(fieldType)) {
|
|
353
|
+
errors.push(`Unsupported ${configType} type '${fieldType}' for field '${fieldName}'. Supported types: ${supportedTypes.join(', ')}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Warn about legacy array type but don't error
|
|
357
|
+
if (fieldType === 'array') {
|
|
358
|
+
if (this.opts.debugMode) {
|
|
359
|
+
console.log(`⚠️ Legacy array type '${fieldType}' for field '${fieldName}'. Consider using 'array:string' for better performance.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check for common mistakes
|
|
364
|
+
if (fieldType === 'array:') {
|
|
365
|
+
errors.push(`Incomplete array type '${fieldType}' for field '${fieldName}'. Must specify element type after colon: array:string, array:number, or array:boolean`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (errors.length > 0) {
|
|
369
|
+
throw new Error(`${configType.charAt(0).toUpperCase() + configType.slice(1)} configuration errors:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Validate index fields array
|
|
375
|
+
*/
|
|
376
|
+
validateIndexFields(indexFields) {
|
|
377
|
+
if (!this.opts.fields) {
|
|
378
|
+
throw new Error('Index fields array requires fields configuration. Use: { fields: {...}, indexes: [...] }');
|
|
379
|
+
}
|
|
380
|
+
const availableFields = Object.keys(this.opts.fields);
|
|
381
|
+
const errors = [];
|
|
382
|
+
for (const fieldName of indexFields) {
|
|
383
|
+
if (!availableFields.includes(fieldName)) {
|
|
384
|
+
errors.push(`Index field '${fieldName}' not found in fields configuration. Available fields: ${availableFields.join(', ')}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (errors.length > 0) {
|
|
388
|
+
throw new Error(`Index configuration errors:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Prepare index configuration for IndexManager
|
|
394
|
+
*/
|
|
395
|
+
prepareIndexConfiguration() {
|
|
396
|
+
// Convert new fields/indexes format to legacy format for IndexManager
|
|
397
|
+
if (this.opts.fields && Array.isArray(this.opts.indexes)) {
|
|
398
|
+
// New format: { fields: {...}, indexes: [...] }
|
|
399
|
+
const indexedFields = {};
|
|
400
|
+
const originalIndexes = [...this.opts.indexes]; // Keep original for validation
|
|
401
|
+
|
|
402
|
+
for (const fieldName of this.opts.indexes) {
|
|
403
|
+
if (this.opts.fields[fieldName]) {
|
|
404
|
+
indexedFields[fieldName] = this.opts.fields[fieldName];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Store original indexes for validation
|
|
409
|
+
this.opts.originalIndexes = originalIndexes;
|
|
410
|
+
|
|
411
|
+
// Replace indexes array with object for IndexManager
|
|
412
|
+
this.opts.indexes = indexedFields;
|
|
413
|
+
if (this.opts.debugMode) {
|
|
414
|
+
console.log(`🔍 Converted fields/indexes format: ${Object.keys(indexedFields).join(', ')} [${this.instanceId}]`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Legacy format (indexes as object) is already compatible
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Initialize all managers
|
|
422
|
+
*/
|
|
423
|
+
initializeManagers() {
|
|
424
|
+
// CRITICAL FIX: Prevent double initialization which corrupts term mappings
|
|
425
|
+
if (this.managersInitialized) {
|
|
426
|
+
if (this.opts.debugMode) {
|
|
427
|
+
console.log(`⚠️ initializeManagers() called again - skipping to prevent corruption [${this.instanceId}]`);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// CRITICAL FIX: Initialize serializer first - this was missing and causing crashes
|
|
433
|
+
this.serializer = new _Serializer.default(this.opts);
|
|
434
|
+
|
|
435
|
+
// Initialize schema for array-based serialization
|
|
436
|
+
if (this.opts.enableArraySerialization !== false) {
|
|
437
|
+
this.initializeSchema();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Initialize TermManager - always enabled for optimal performance
|
|
441
|
+
this.termManager = new _TermManager.default();
|
|
442
|
+
|
|
443
|
+
// Auto-detect term mapping fields from indexes
|
|
444
|
+
const termMappingFields = this.getTermMappingFields();
|
|
445
|
+
this.termManager.termMappingFields = termMappingFields;
|
|
446
|
+
this.opts.termMapping = true; // Always enable term mapping for optimal performance
|
|
447
|
+
|
|
448
|
+
if (this.opts.debugMode) {
|
|
449
|
+
if (termMappingFields.length > 0) {
|
|
450
|
+
console.log(`🔍 TermManager initialized for fields: ${termMappingFields.join(', ')} [${this.instanceId}]`);
|
|
451
|
+
} else {
|
|
452
|
+
console.log(`🔍 TermManager initialized (no array:string fields detected) [${this.instanceId}]`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Prepare index configuration for IndexManager
|
|
457
|
+
this.prepareIndexConfiguration();
|
|
458
|
+
|
|
459
|
+
// Validate configuration after conversion
|
|
460
|
+
this.validateIndexConfiguration();
|
|
461
|
+
|
|
462
|
+
// Initialize IndexManager with database reference for term mapping
|
|
463
|
+
this.indexManager = new _IndexManager.default(this.opts, null, this);
|
|
464
|
+
if (this.opts.debugMode) {
|
|
465
|
+
console.log(`🔍 IndexManager initialized with fields: ${this.indexManager.indexedFields.join(', ')} [${this.instanceId}]`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Mark managers as initialized
|
|
469
|
+
this.managersInitialized = true;
|
|
470
|
+
this.indexOffset = 0;
|
|
471
|
+
this.writeBuffer = [];
|
|
472
|
+
this.writeBufferOffsets = []; // Track offsets for writeBuffer records
|
|
473
|
+
this.writeBufferSizes = []; // Track sizes for writeBuffer records
|
|
474
|
+
this.isInsideOperationQueue = false; // Flag to prevent deadlock in save() calls
|
|
475
|
+
|
|
476
|
+
// Initialize other managers
|
|
477
|
+
this.fileHandler = new _FileHandler.default(this.normalizedFile, this.fileMutex, this.opts);
|
|
478
|
+
this.queryManager = new _QueryManager.QueryManager(this);
|
|
479
|
+
this.concurrencyManager = new _ConcurrencyManager.ConcurrencyManager(this.opts);
|
|
480
|
+
this.statisticsManager = new _StatisticsManager.StatisticsManager(this, this.opts);
|
|
481
|
+
this.streamingProcessor = new _StreamingProcessor.default(this.opts);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Get term mapping fields from indexes (auto-detected)
|
|
486
|
+
* @returns {string[]} Array of field names that use term mapping
|
|
487
|
+
*/
|
|
488
|
+
getTermMappingFields() {
|
|
489
|
+
if (!this.opts.indexes) return [];
|
|
490
|
+
|
|
491
|
+
// Auto-detect fields that benefit from term mapping
|
|
492
|
+
const termMappingFields = [];
|
|
493
|
+
for (const [field, type] of Object.entries(this.opts.indexes)) {
|
|
494
|
+
// Fields that should use term mapping
|
|
495
|
+
if (type === 'array:string' || type === 'string') {
|
|
496
|
+
termMappingFields.push(field);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return termMappingFields;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* CRITICAL FIX: Validate database state before critical operations
|
|
504
|
+
* Prevents crashes from undefined methods and invalid states
|
|
505
|
+
*/
|
|
506
|
+
validateState() {
|
|
507
|
+
if (this.destroyed) {
|
|
508
|
+
throw new Error('Database is destroyed');
|
|
509
|
+
}
|
|
510
|
+
if (this.closed) {
|
|
511
|
+
throw new Error('Database is closed. Call init() to reopen it.');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Allow operations during destroying phase for proper cleanup
|
|
515
|
+
|
|
516
|
+
if (!this.serializer) {
|
|
517
|
+
throw new Error('Database serializer not initialized - this indicates a critical bug');
|
|
518
|
+
}
|
|
519
|
+
if (!this.normalizedFile) {
|
|
520
|
+
throw new Error('Database file path not set - this indicates file path management failure');
|
|
521
|
+
}
|
|
522
|
+
if (!this.fileHandler) {
|
|
523
|
+
throw new Error('Database file handler not initialized');
|
|
524
|
+
}
|
|
525
|
+
if (!this.indexManager) {
|
|
526
|
+
throw new Error('Database index manager not initialized');
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* CRITICAL FIX: Ensure file path is valid and accessible
|
|
533
|
+
* Prevents file path loss issues mentioned in crash report
|
|
534
|
+
*/
|
|
535
|
+
ensureFilePath() {
|
|
536
|
+
if (!this.normalizedFile) {
|
|
537
|
+
throw new Error('Database file path is missing after initialization - this indicates a critical file path management failure');
|
|
538
|
+
}
|
|
539
|
+
return this.normalizedFile;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Normalize file path to ensure it ends with .jdb
|
|
544
|
+
*/
|
|
545
|
+
normalizeFilePath(file) {
|
|
546
|
+
if (!file) return null;
|
|
547
|
+
return file.endsWith('.jdb') ? file : `${file}.jdb`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Initialize the database
|
|
552
|
+
*/
|
|
553
|
+
async initialize() {
|
|
554
|
+
// Check if database is destroyed first (before checking initialized)
|
|
555
|
+
if (this.destroyed) {
|
|
556
|
+
throw new Error('Cannot initialize destroyed database. Use a new instance instead.');
|
|
557
|
+
}
|
|
558
|
+
if (this.initialized) return;
|
|
559
|
+
|
|
560
|
+
// Prevent concurrent initialization - wait for ongoing init to complete
|
|
561
|
+
if (this.isLoading) {
|
|
562
|
+
if (this.opts.debugMode) {
|
|
563
|
+
console.log('🔄 init() already in progress - waiting for completion');
|
|
564
|
+
}
|
|
565
|
+
// Wait for ongoing initialization to complete
|
|
566
|
+
while (this.isLoading) {
|
|
567
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
568
|
+
}
|
|
569
|
+
// Check if initialization completed successfully
|
|
570
|
+
if (this.initialized) {
|
|
571
|
+
if (this.opts.debugMode) {
|
|
572
|
+
console.log('✅ Concurrent init() completed - database is now initialized');
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// If we get here, initialization failed - we can try again
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
this.isLoading = true;
|
|
580
|
+
|
|
581
|
+
// Reset closed state when reinitializing
|
|
582
|
+
this.closed = false;
|
|
583
|
+
|
|
584
|
+
// Initialize managers (protected against double initialization)
|
|
585
|
+
this.initializeManagers();
|
|
586
|
+
|
|
587
|
+
// Handle clear option - delete existing files before loading
|
|
588
|
+
if (this.opts.clear && this.normalizedFile) {
|
|
589
|
+
await this.clearExistingFiles();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Check file existence and handle create option
|
|
593
|
+
if (this.normalizedFile) {
|
|
594
|
+
const fileExists = await this.fileHandler.exists();
|
|
595
|
+
if (!fileExists) {
|
|
596
|
+
if (!this.opts.create) {
|
|
597
|
+
throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`);
|
|
598
|
+
}
|
|
599
|
+
// File will be created when first data is written
|
|
600
|
+
} else {
|
|
601
|
+
// Load existing data if file exists
|
|
602
|
+
await this.load();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Manual save is now the default behavior
|
|
607
|
+
|
|
608
|
+
this.initialized = true;
|
|
609
|
+
this.emit('initialized');
|
|
610
|
+
if (this.opts.debugMode) {
|
|
611
|
+
console.log(`✅ Database initialized with ${this.writeBuffer.length} records`);
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
console.error('Failed to initialize database:', error);
|
|
615
|
+
throw error;
|
|
616
|
+
} finally {
|
|
617
|
+
this.isLoading = false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Validate that the database is initialized before performing operations
|
|
623
|
+
* @param {string} operation - The operation being attempted
|
|
624
|
+
* @throws {Error} If database is not initialized
|
|
625
|
+
*/
|
|
626
|
+
_validateInitialization(operation) {
|
|
627
|
+
if (this.destroyed) {
|
|
628
|
+
throw new Error(`❌ Cannot perform '${operation}' on a destroyed database. Create a new instance instead.`);
|
|
629
|
+
}
|
|
630
|
+
if (this.closed) {
|
|
631
|
+
throw new Error(`❌ Database is closed. Call 'await db.init()' to reopen it before performing '${operation}' operations.`);
|
|
632
|
+
}
|
|
633
|
+
if (!this.initialized) {
|
|
634
|
+
const errorMessage = `❌ Database not initialized. Call 'await db.init()' before performing '${operation}' operations.\n\n` + `Example:\n` + ` const db = new Database('./myfile.jdb')\n` + ` await db.init() // ← Required before any operations\n` + ` await db.insert({ name: 'test' }) // ← Now you can use database operations\n\n` + `File: ${this.normalizedFile || 'unknown'}`;
|
|
635
|
+
throw new Error(errorMessage);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Clear existing database files (.jdb and .idx.jdb)
|
|
641
|
+
*/
|
|
642
|
+
async clearExistingFiles() {
|
|
643
|
+
if (!this.normalizedFile) return;
|
|
644
|
+
try {
|
|
645
|
+
// Clear main database file
|
|
646
|
+
if (await this.fileHandler.exists()) {
|
|
647
|
+
await this.fileHandler.delete();
|
|
648
|
+
if (this.opts.debugMode) {
|
|
649
|
+
console.log(`🗑️ Cleared database file: ${this.normalizedFile}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Clear index file
|
|
654
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
655
|
+
const idxFileHandler = new _FileHandler.default(idxPath, this.fileMutex, this.opts);
|
|
656
|
+
if (await idxFileHandler.exists()) {
|
|
657
|
+
await idxFileHandler.delete();
|
|
658
|
+
if (this.opts.debugMode) {
|
|
659
|
+
console.log(`🗑️ Cleared index file: ${idxPath}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Reset internal state
|
|
664
|
+
this.offsets = [];
|
|
665
|
+
this.indexOffset = 0;
|
|
666
|
+
this.deletedIds.clear();
|
|
667
|
+
this.shouldSave = false;
|
|
668
|
+
|
|
669
|
+
// Create empty files to ensure they exist
|
|
670
|
+
await this.fileHandler.writeAll('');
|
|
671
|
+
await idxFileHandler.writeAll('');
|
|
672
|
+
if (this.opts.debugMode) {
|
|
673
|
+
console.log('🗑️ Database cleared successfully');
|
|
674
|
+
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.error('Failed to clear existing files:', error);
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Load data from file
|
|
683
|
+
*/
|
|
684
|
+
async load() {
|
|
685
|
+
if (!this.normalizedFile) return;
|
|
686
|
+
try {
|
|
687
|
+
const startTime = Date.now();
|
|
688
|
+
this.isLoading = true;
|
|
689
|
+
|
|
690
|
+
// Don't load the entire file - just initialize empty state
|
|
691
|
+
// The actual record count will come from loaded offsets
|
|
692
|
+
this.writeBuffer = []; // writeBuffer is only for new unsaved records
|
|
693
|
+
|
|
694
|
+
// recordCount will be determined from loaded offsets
|
|
695
|
+
// If no offsets were loaded, we'll count records only if needed
|
|
696
|
+
|
|
697
|
+
// Load index data if available (always try to load offsets, even without indexed fields)
|
|
698
|
+
if (this.indexManager) {
|
|
699
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
700
|
+
try {
|
|
701
|
+
const idxFileHandler = new _FileHandler.default(idxPath, this.fileMutex, this.opts);
|
|
702
|
+
const idxData = await idxFileHandler.readAll();
|
|
703
|
+
if (idxData && idxData.trim()) {
|
|
704
|
+
const parsedIdxData = JSON.parse(idxData);
|
|
705
|
+
|
|
706
|
+
// Always load offsets if available (even without indexed fields)
|
|
707
|
+
if (parsedIdxData.offsets && Array.isArray(parsedIdxData.offsets)) {
|
|
708
|
+
this.offsets = parsedIdxData.offsets;
|
|
709
|
+
if (this.opts.debugMode) {
|
|
710
|
+
console.log(`📂 Loaded ${this.offsets.length} offsets from ${idxPath}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Load indexOffset for proper range calculations
|
|
715
|
+
if (parsedIdxData.indexOffset !== undefined) {
|
|
716
|
+
this.indexOffset = parsedIdxData.indexOffset;
|
|
717
|
+
if (this.opts.debugMode) {
|
|
718
|
+
console.log(`📂 Loaded indexOffset: ${this.indexOffset} from ${idxPath}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Load index data only if available and we have indexed fields
|
|
723
|
+
if (parsedIdxData && parsedIdxData.index && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
|
|
724
|
+
this.indexManager.load(parsedIdxData.index);
|
|
725
|
+
|
|
726
|
+
// Load term mapping data from .idx file if it exists
|
|
727
|
+
if (parsedIdxData.termMapping && this.termManager) {
|
|
728
|
+
await this.termManager.loadTerms(parsedIdxData.termMapping);
|
|
729
|
+
if (this.opts.debugMode) {
|
|
730
|
+
console.log(`📂 Loaded term mapping from ${idxPath}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (this.opts.debugMode) {
|
|
734
|
+
console.log(`📂 Loaded index data from ${idxPath}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Load configuration from .idx file if database exists
|
|
739
|
+
if (parsedIdxData.config) {
|
|
740
|
+
const config = parsedIdxData.config;
|
|
741
|
+
|
|
742
|
+
// Override constructor options with saved configuration
|
|
743
|
+
if (config.fields) {
|
|
744
|
+
this.opts.fields = config.fields;
|
|
745
|
+
if (this.opts.debugMode) {
|
|
746
|
+
console.log(`📂 Loaded fields config from ${idxPath}:`, Object.keys(config.fields));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (config.indexes) {
|
|
750
|
+
this.opts.indexes = config.indexes;
|
|
751
|
+
if (this.opts.debugMode) {
|
|
752
|
+
console.log(`📂 Loaded indexes config from ${idxPath}:`, Object.keys(config.indexes));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (config.originalIndexes) {
|
|
756
|
+
this.opts.originalIndexes = config.originalIndexes;
|
|
757
|
+
if (this.opts.debugMode) {
|
|
758
|
+
console.log(`📂 Loaded originalIndexes config from ${idxPath}:`, config.originalIndexes.length, 'indexes');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Reinitialize schema from saved configuration
|
|
763
|
+
if (config.schema && this.serializer) {
|
|
764
|
+
this.serializer.initializeSchema(config.schema);
|
|
765
|
+
if (this.opts.debugMode) {
|
|
766
|
+
console.log(`📂 Loaded schema from ${idxPath}:`, config.schema.join(', '));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch (idxError) {
|
|
772
|
+
// Index file doesn't exist or is corrupted, rebuild from data
|
|
773
|
+
if (this.opts.debugMode) {
|
|
774
|
+
console.log('📂 No index file found, rebuilding indexes from data');
|
|
775
|
+
}
|
|
776
|
+
// We can't rebuild index without violating no-memory-storage rule
|
|
777
|
+
// Index will be rebuilt as needed during queries
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
// No indexed fields, no need to rebuild indexes
|
|
781
|
+
}
|
|
782
|
+
this.performanceStats.loads++;
|
|
783
|
+
this.performanceStats.loadTime += Date.now() - startTime;
|
|
784
|
+
this.emit('loaded', this.writeBuffer.length);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
console.error('Failed to load database:', error);
|
|
787
|
+
throw error;
|
|
788
|
+
} finally {
|
|
789
|
+
this.isLoading = false;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Save data to file
|
|
795
|
+
* @param {boolean} inQueue - Whether to execute within the operation queue (default: false)
|
|
796
|
+
*/
|
|
797
|
+
async save(inQueue = false) {
|
|
798
|
+
this._validateInitialization('save');
|
|
799
|
+
if (this.opts.debugMode) {
|
|
800
|
+
console.log(`💾 save() called: writeBuffer.length=${this.writeBuffer.length}, offsets.length=${this.offsets.length}`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Auto-save removed - no need to pause anything
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
// CRITICAL FIX: Wait for any ongoing save operations to complete
|
|
807
|
+
if (this.isSaving) {
|
|
808
|
+
if (this.opts.debugMode) {
|
|
809
|
+
console.log('💾 save(): waiting for previous save to complete');
|
|
810
|
+
}
|
|
811
|
+
// Wait for previous save to complete
|
|
812
|
+
while (this.isSaving) {
|
|
813
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Check if data changed since the previous save completed
|
|
817
|
+
const hasDataToSave = this.writeBuffer.length > 0 || this.deletedIds.size > 0;
|
|
818
|
+
const needsStructureCreation = this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0;
|
|
819
|
+
if (!hasDataToSave && !needsStructureCreation) {
|
|
820
|
+
if (this.opts.debugMode) {
|
|
821
|
+
console.log('💾 Save: No new data to save since previous save completed');
|
|
822
|
+
}
|
|
823
|
+
return; // Nothing new to save
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// CRITICAL FIX: Check if there's actually data to save before proceeding
|
|
828
|
+
// But allow save if we need to create database structure (index files, etc.)
|
|
829
|
+
const hasDataToSave = this.writeBuffer.length > 0 || this.deletedIds.size > 0;
|
|
830
|
+
const needsStructureCreation = this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0;
|
|
831
|
+
if (!hasDataToSave && !needsStructureCreation) {
|
|
832
|
+
if (this.opts.debugMode) {
|
|
833
|
+
console.log('💾 Save: No data to save (writeBuffer empty and no deleted records)');
|
|
834
|
+
}
|
|
835
|
+
return; // Nothing to save
|
|
836
|
+
}
|
|
837
|
+
if (inQueue) {
|
|
838
|
+
if (this.opts.debugMode) {
|
|
839
|
+
console.log(`💾 save(): executing in queue`);
|
|
840
|
+
}
|
|
841
|
+
return this.operationQueue.enqueue(async () => {
|
|
842
|
+
return this._doSave();
|
|
843
|
+
});
|
|
844
|
+
} else {
|
|
845
|
+
if (this.opts.debugMode) {
|
|
846
|
+
console.log(`💾 save(): calling _doSave() directly`);
|
|
847
|
+
}
|
|
848
|
+
return this._doSave();
|
|
849
|
+
}
|
|
850
|
+
} finally {
|
|
851
|
+
// Auto-save removed - no need to resume anything
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Internal save implementation (without queue)
|
|
857
|
+
*/
|
|
858
|
+
async _doSave() {
|
|
859
|
+
// CRITICAL FIX: Check if database is destroyed
|
|
860
|
+
if (this.destroyed) return;
|
|
861
|
+
|
|
862
|
+
// CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
|
|
863
|
+
if (this.isSaving) {
|
|
864
|
+
if (this.opts.debugMode) {
|
|
865
|
+
console.log('💾 _doSave: Save operation already in progress, skipping');
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// CRITICAL FIX: Check if there's actually data to save or structure to create
|
|
871
|
+
const hasDataToSave = this.writeBuffer.length > 0 || this.deletedIds.size > 0;
|
|
872
|
+
const needsStructureCreation = this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0;
|
|
873
|
+
if (!hasDataToSave && !needsStructureCreation) {
|
|
874
|
+
if (this.opts.debugMode) {
|
|
875
|
+
console.log('💾 _doSave: No data to save (writeBuffer empty and no deleted records)');
|
|
876
|
+
}
|
|
877
|
+
return; // Nothing to save
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// CRITICAL FIX: Set saving flag immediately to prevent race conditions
|
|
881
|
+
this.isSaving = true;
|
|
882
|
+
try {
|
|
883
|
+
const startTime = Date.now();
|
|
884
|
+
|
|
885
|
+
// CRITICAL FIX: Ensure file path is valid
|
|
886
|
+
this.ensureFilePath();
|
|
887
|
+
|
|
888
|
+
// CRITICAL FIX: Wait for ALL pending operations to complete before save
|
|
889
|
+
await this._waitForPendingOperations();
|
|
890
|
+
|
|
891
|
+
// CRITICAL FIX: Capture writeBuffer and deletedIds at the start to prevent race conditions
|
|
892
|
+
const writeBufferSnapshot = [...this.writeBuffer];
|
|
893
|
+
const deletedIdsSnapshot = new Set(this.deletedIds);
|
|
894
|
+
|
|
895
|
+
// OPTIMIZATION: Process pending index updates in batch before save
|
|
896
|
+
if (this.pendingIndexUpdates && this.pendingIndexUpdates.length > 0) {
|
|
897
|
+
if (this.opts.debugMode) {
|
|
898
|
+
console.log(`💾 Save: Processing ${this.pendingIndexUpdates.length} pending index updates in batch`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Extract records and line numbers for batch processing
|
|
902
|
+
const records = this.pendingIndexUpdates.map(update => update.record);
|
|
903
|
+
const startLineNumber = this.pendingIndexUpdates[0].lineNumber;
|
|
904
|
+
|
|
905
|
+
// Process index updates in batch
|
|
906
|
+
await this.indexManager.addBatch(records, startLineNumber);
|
|
907
|
+
|
|
908
|
+
// Clear pending updates
|
|
909
|
+
this.pendingIndexUpdates = [];
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// CRITICAL FIX: Flush write buffer completely after capturing snapshot
|
|
913
|
+
await this._flushWriteBufferCompletely();
|
|
914
|
+
|
|
915
|
+
// CRITICAL FIX: Wait for all I/O operations to complete before clearing writeBuffer
|
|
916
|
+
await this._waitForIOCompletion();
|
|
917
|
+
|
|
918
|
+
// CRITICAL FIX: Verify write buffer is empty after I/O completion
|
|
919
|
+
// But allow for ongoing insertions during high-volume scenarios
|
|
920
|
+
if (this.writeBuffer.length > 0) {
|
|
921
|
+
if (this.opts.debugMode) {
|
|
922
|
+
console.log(`💾 Save: WriteBuffer still has ${this.writeBuffer.length} items after flush - this may indicate ongoing insertions`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// If we have a reasonable number of items, continue processing
|
|
926
|
+
if (this.writeBuffer.length < 10000) {
|
|
927
|
+
// Reasonable threshold
|
|
928
|
+
if (this.opts.debugMode) {
|
|
929
|
+
console.log(`💾 Save: Continuing to process remaining ${this.writeBuffer.length} items`);
|
|
930
|
+
}
|
|
931
|
+
// Continue with the save process - the remaining items will be included in the final save
|
|
932
|
+
} else {
|
|
933
|
+
// Too many items remaining - likely a real problem
|
|
934
|
+
throw new Error(`WriteBuffer has too many items after flush: ${this.writeBuffer.length} items remaining (threshold: 10000)`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// OPTIMIZATION: Parallel operations - cleanup and data preparation
|
|
939
|
+
let allData = [];
|
|
940
|
+
let orphanedCount = 0;
|
|
941
|
+
|
|
942
|
+
// Check if there are new records to save (after flush, writeBuffer should be empty)
|
|
943
|
+
if (this.opts.debugMode) {
|
|
944
|
+
console.log(`💾 Save: writeBuffer.length=${this.writeBuffer.length}, writeBufferSnapshot.length=${writeBufferSnapshot.length}`);
|
|
945
|
+
}
|
|
946
|
+
if (this.writeBuffer.length > 0) {
|
|
947
|
+
if (this.opts.debugMode) {
|
|
948
|
+
console.log(`💾 Save: WriteBuffer has ${writeBufferSnapshot.length} records, using streaming approach`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Note: processTermMapping is already called during insert/update operations
|
|
952
|
+
// No need to call it again here to avoid double processing
|
|
953
|
+
|
|
954
|
+
// OPTIMIZATION: Check if we can skip reading existing records
|
|
955
|
+
// Only use streaming if we have existing records AND we're not just appending new records
|
|
956
|
+
const hasExistingRecords = this.indexOffset > 0 && this.offsets.length > 0 && writeBufferSnapshot.length > 0;
|
|
957
|
+
if (!hasExistingRecords && deletedIdsSnapshot.size === 0) {
|
|
958
|
+
// OPTIMIZATION: No existing records to read, just use writeBuffer
|
|
959
|
+
allData = [...writeBufferSnapshot];
|
|
960
|
+
} else {
|
|
961
|
+
// OPTIMIZATION: Parallel operations - cleanup and streaming
|
|
962
|
+
const parallelOperations = [];
|
|
963
|
+
|
|
964
|
+
// Add term cleanup if enabled
|
|
965
|
+
if (this.opts.termMappingCleanup && this.termManager) {
|
|
966
|
+
parallelOperations.push(Promise.resolve().then(() => {
|
|
967
|
+
orphanedCount = this.termManager.cleanupOrphanedTerms();
|
|
968
|
+
if (this.opts.debugMode && orphanedCount > 0) {
|
|
969
|
+
console.log(`🧹 Cleaned up ${orphanedCount} orphaned terms`);
|
|
970
|
+
}
|
|
971
|
+
}));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Add streaming operation
|
|
975
|
+
parallelOperations.push(this._streamExistingRecords(deletedIdsSnapshot, writeBufferSnapshot).then(existingRecords => {
|
|
976
|
+
allData = [...existingRecords];
|
|
977
|
+
|
|
978
|
+
// OPTIMIZATION: Use Map for faster lookups
|
|
979
|
+
const existingRecordMap = new Map(existingRecords.filter(r => r && r.id).map(r => [r.id, r]));
|
|
980
|
+
for (const record of writeBufferSnapshot) {
|
|
981
|
+
if (!deletedIdsSnapshot.has(record.id)) {
|
|
982
|
+
if (existingRecordMap.has(record.id)) {
|
|
983
|
+
// Replace existing record
|
|
984
|
+
const existingIndex = allData.findIndex(r => r.id === record.id);
|
|
985
|
+
allData[existingIndex] = record;
|
|
986
|
+
} else {
|
|
987
|
+
// Add new record
|
|
988
|
+
allData.push(record);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}));
|
|
993
|
+
|
|
994
|
+
// Execute parallel operations
|
|
995
|
+
await Promise.all(parallelOperations);
|
|
996
|
+
}
|
|
997
|
+
} else {
|
|
998
|
+
// CRITICAL FIX: When writeBuffer is empty, use streaming approach for existing records
|
|
999
|
+
if (this.opts.debugMode) {
|
|
1000
|
+
console.log(`💾 Save: Checking streaming condition: indexOffset=${this.indexOffset}, deletedIds.size=${this.deletedIds.size}`);
|
|
1001
|
+
console.log(`💾 Save: writeBuffer.length=${this.writeBuffer.length}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (this.indexOffset > 0 || this.deletedIds.size > 0) {
|
|
1004
|
+
try {
|
|
1005
|
+
if (this.opts.debugMode) {
|
|
1006
|
+
console.log(`💾 Save: Using streaming approach for existing records`);
|
|
1007
|
+
console.log(`💾 Save: indexOffset: ${this.indexOffset}, offsets.length: ${this.offsets.length}`);
|
|
1008
|
+
console.log(`💾 Save: deletedIds to filter:`, Array.from(deletedIdsSnapshot));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// OPTIMIZATION: Parallel operations - cleanup and streaming
|
|
1012
|
+
const parallelOperations = [];
|
|
1013
|
+
|
|
1014
|
+
// Add term cleanup if enabled
|
|
1015
|
+
if (this.opts.termMappingCleanup && this.termManager) {
|
|
1016
|
+
parallelOperations.push(Promise.resolve().then(() => {
|
|
1017
|
+
orphanedCount = this.termManager.cleanupOrphanedTerms();
|
|
1018
|
+
if (this.opts.debugMode && orphanedCount > 0) {
|
|
1019
|
+
console.log(`🧹 Cleaned up ${orphanedCount} orphaned terms`);
|
|
1020
|
+
}
|
|
1021
|
+
}));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Add streaming operation
|
|
1025
|
+
parallelOperations.push(this._streamExistingRecords(deletedIdsSnapshot, writeBufferSnapshot).then(existingRecords => {
|
|
1026
|
+
if (this.opts.debugMode) {
|
|
1027
|
+
console.log(`💾 Save: _streamExistingRecords returned ${existingRecords.length} records`);
|
|
1028
|
+
console.log(`💾 Save: existingRecords:`, existingRecords);
|
|
1029
|
+
}
|
|
1030
|
+
// Combine existing records with new records from writeBuffer
|
|
1031
|
+
allData = [...existingRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))];
|
|
1032
|
+
}).catch(error => {
|
|
1033
|
+
if (this.opts.debugMode) {
|
|
1034
|
+
console.log(`💾 Save: _streamExistingRecords failed:`, error.message);
|
|
1035
|
+
}
|
|
1036
|
+
// CRITICAL FIX: Use safe fallback to preserve existing data instead of losing it
|
|
1037
|
+
return this._loadExistingRecordsFallback(deletedIdsSnapshot, writeBufferSnapshot).then(fallbackRecords => {
|
|
1038
|
+
allData = [...fallbackRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))];
|
|
1039
|
+
if (this.opts.debugMode) {
|
|
1040
|
+
console.log(`💾 Save: Fallback preserved ${fallbackRecords.length} existing records, total: ${allData.length}`);
|
|
1041
|
+
}
|
|
1042
|
+
}).catch(fallbackError => {
|
|
1043
|
+
if (this.opts.debugMode) {
|
|
1044
|
+
console.log(`💾 Save: All fallback methods failed:`, fallbackError.message);
|
|
1045
|
+
console.log(`💾 Save: CRITICAL - Data loss may occur, only writeBuffer will be saved`);
|
|
1046
|
+
}
|
|
1047
|
+
// Last resort: at least save what we have in writeBuffer
|
|
1048
|
+
allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id));
|
|
1049
|
+
});
|
|
1050
|
+
}));
|
|
1051
|
+
|
|
1052
|
+
// Execute parallel operations
|
|
1053
|
+
await Promise.all(parallelOperations);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (this.opts.debugMode) {
|
|
1056
|
+
console.log(`💾 Save: Streaming approach failed, falling back to writeBuffer only: ${error.message}`);
|
|
1057
|
+
}
|
|
1058
|
+
// CRITICAL FIX: Use safe fallback to preserve existing data instead of losing it
|
|
1059
|
+
try {
|
|
1060
|
+
const fallbackRecords = await this._loadExistingRecordsFallback(deletedIdsSnapshot, writeBufferSnapshot);
|
|
1061
|
+
allData = [...fallbackRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))];
|
|
1062
|
+
if (this.opts.debugMode) {
|
|
1063
|
+
console.log(`💾 Save: Fallback preserved ${fallbackRecords.length} existing records, total: ${allData.length}`);
|
|
1064
|
+
}
|
|
1065
|
+
} catch (fallbackError) {
|
|
1066
|
+
if (this.opts.debugMode) {
|
|
1067
|
+
console.log(`💾 Save: All fallback methods failed:`, fallbackError.message);
|
|
1068
|
+
console.log(`💾 Save: CRITICAL - Data loss may occur, only writeBuffer will be saved`);
|
|
1069
|
+
}
|
|
1070
|
+
// Last resort: at least save what we have in writeBuffer
|
|
1071
|
+
allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
} else {
|
|
1075
|
+
// No existing data, use only writeBuffer
|
|
1076
|
+
allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// CRITICAL FIX: Calculate offsets based on actual serialized data that will be written
|
|
1081
|
+
// This ensures consistency between offset calculation and file writing
|
|
1082
|
+
const jsonlData = allData.length > 0 ? this.serializer.serializeBatch(allData) : '';
|
|
1083
|
+
const jsonlString = jsonlData.toString('utf8');
|
|
1084
|
+
const lines = jsonlString.split('\n').filter(line => line.trim());
|
|
1085
|
+
this.offsets = [];
|
|
1086
|
+
let currentOffset = 0;
|
|
1087
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1088
|
+
this.offsets.push(currentOffset);
|
|
1089
|
+
// CRITICAL FIX: Use actual line length including newline for accurate offset calculation
|
|
1090
|
+
// This accounts for UTF-8 encoding differences (e.g., 'ação' vs 'acao')
|
|
1091
|
+
const lineWithNewline = lines[i] + '\n';
|
|
1092
|
+
currentOffset += Buffer.byteLength(lineWithNewline, 'utf8');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// CRITICAL FIX: Ensure indexOffset matches actual file size
|
|
1096
|
+
this.indexOffset = currentOffset;
|
|
1097
|
+
if (this.opts.debugMode) {
|
|
1098
|
+
console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// OPTIMIZATION: Parallel operations - file writing and index data preparation
|
|
1102
|
+
const parallelWriteOperations = [];
|
|
1103
|
+
|
|
1104
|
+
// Add main file write operation
|
|
1105
|
+
parallelWriteOperations.push(this.fileHandler.writeBatch([jsonlData]));
|
|
1106
|
+
|
|
1107
|
+
// Add index file operations - ALWAYS save offsets, even without indexed fields
|
|
1108
|
+
if (this.indexManager) {
|
|
1109
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
1110
|
+
|
|
1111
|
+
// OPTIMIZATION: Parallel data preparation
|
|
1112
|
+
const indexDataPromise = Promise.resolve({
|
|
1113
|
+
index: this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {},
|
|
1114
|
+
offsets: this.offsets,
|
|
1115
|
+
// Save actual offsets for efficient file operations
|
|
1116
|
+
indexOffset: this.indexOffset // Save file size for proper range calculations
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// Add term mapping data if needed
|
|
1120
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1121
|
+
if (termMappingFields.length > 0 && this.termManager) {
|
|
1122
|
+
const termDataPromise = this.termManager.saveTerms();
|
|
1123
|
+
|
|
1124
|
+
// Combine index data and term data
|
|
1125
|
+
const combinedDataPromise = Promise.all([indexDataPromise, termDataPromise]).then(([indexData, termData]) => {
|
|
1126
|
+
indexData.termMapping = termData;
|
|
1127
|
+
return indexData;
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
// Add index file write operation
|
|
1131
|
+
parallelWriteOperations.push(combinedDataPromise.then(indexData => {
|
|
1132
|
+
const idxFileHandler = new _FileHandler.default(idxPath, this.fileMutex, this.opts);
|
|
1133
|
+
return idxFileHandler.writeAll(JSON.stringify(indexData, null, 2));
|
|
1134
|
+
}));
|
|
1135
|
+
} else {
|
|
1136
|
+
// Add index file write operation without term mapping
|
|
1137
|
+
parallelWriteOperations.push(indexDataPromise.then(indexData => {
|
|
1138
|
+
const idxFileHandler = new _FileHandler.default(idxPath, this.fileMutex, this.opts);
|
|
1139
|
+
return idxFileHandler.writeAll(JSON.stringify(indexData, null, 2));
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Execute parallel write operations
|
|
1145
|
+
await Promise.all(parallelWriteOperations);
|
|
1146
|
+
if (this.opts.debugMode) {
|
|
1147
|
+
console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// CRITICAL FIX: Invalidate file size cache after save operation
|
|
1151
|
+
this._cachedFileStats = null;
|
|
1152
|
+
this.shouldSave = false;
|
|
1153
|
+
this.lastSaveTime = Date.now();
|
|
1154
|
+
|
|
1155
|
+
// Clear writeBuffer and deletedIds after successful save only if we had data to save
|
|
1156
|
+
if (allData.length > 0) {
|
|
1157
|
+
// Rebuild index when records were deleted to maintain consistency
|
|
1158
|
+
const hadDeletedRecords = deletedIdsSnapshot.size > 0;
|
|
1159
|
+
if (this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
|
|
1160
|
+
if (hadDeletedRecords) {
|
|
1161
|
+
// Clear the index and rebuild it from the remaining records
|
|
1162
|
+
this.indexManager.clear();
|
|
1163
|
+
if (this.opts.debugMode) {
|
|
1164
|
+
console.log(`🧹 Rebuilding index after removing ${deletedIdsSnapshot.size} deleted records`);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Rebuild index from the saved records
|
|
1168
|
+
for (let i = 0; i < allData.length; i++) {
|
|
1169
|
+
const record = allData[i];
|
|
1170
|
+
await this.indexManager.add(record, i);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// CRITICAL FIX: Clear all records that were in the snapshot
|
|
1176
|
+
// Use a more robust comparison that handles different data types
|
|
1177
|
+
const originalLength = this.writeBuffer.length;
|
|
1178
|
+
this.writeBuffer = this.writeBuffer.filter(record => {
|
|
1179
|
+
// For objects with id, compare by id
|
|
1180
|
+
if (typeof record === 'object' && record !== null && record.id) {
|
|
1181
|
+
return !writeBufferSnapshot.some(snapshotRecord => typeof snapshotRecord === 'object' && snapshotRecord !== null && snapshotRecord.id && snapshotRecord.id === record.id);
|
|
1182
|
+
}
|
|
1183
|
+
// For other types (Buffers, primitives), use strict equality
|
|
1184
|
+
return !writeBufferSnapshot.some(snapshotRecord => snapshotRecord === record);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// Remove only the deleted IDs that were in the snapshot
|
|
1188
|
+
for (const deletedId of deletedIdsSnapshot) {
|
|
1189
|
+
this.deletedIds.delete(deletedId);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// CRITICAL FIX: Ensure writeBuffer is completely cleared after successful save
|
|
1193
|
+
if (this.writeBuffer.length > 0) {
|
|
1194
|
+
if (this.opts.debugMode) {
|
|
1195
|
+
console.log(`💾 Save: Force clearing remaining ${this.writeBuffer.length} items from writeBuffer`);
|
|
1196
|
+
}
|
|
1197
|
+
// If there are still items in writeBuffer after filtering, clear them
|
|
1198
|
+
// This prevents the "writeBuffer has records" bug in destroy()
|
|
1199
|
+
this.writeBuffer = [];
|
|
1200
|
+
this.writeBufferOffsets = [];
|
|
1201
|
+
this.writeBufferSizes = [];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// indexOffset already set correctly to currentOffset (total file size) above
|
|
1205
|
+
// No need to override it with record count
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// CRITICAL FIX: Always save index data to file after saving records
|
|
1209
|
+
await this._saveIndexDataToFile();
|
|
1210
|
+
this.performanceStats.saves++;
|
|
1211
|
+
this.performanceStats.saveTime += Date.now() - startTime;
|
|
1212
|
+
this.emit('saved', this.writeBuffer.length);
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
console.error('Failed to save database:', error);
|
|
1215
|
+
throw error;
|
|
1216
|
+
} finally {
|
|
1217
|
+
this.isSaving = false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Process term mapping for a record
|
|
1223
|
+
* @param {Object} record - Record to process
|
|
1224
|
+
* @param {boolean} isUpdate - Whether this is an update operation
|
|
1225
|
+
* @param {Object} oldRecord - Original record (for updates)
|
|
1226
|
+
*/
|
|
1227
|
+
processTermMapping(record, isUpdate = false, oldRecord = null) {
|
|
1228
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1229
|
+
if (!this.termManager || termMappingFields.length === 0) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// CRITICAL FIX: Don't modify the original record object
|
|
1234
|
+
// The record should already be a copy created in insert/update methods
|
|
1235
|
+
// This prevents reference modification issues
|
|
1236
|
+
|
|
1237
|
+
// Process each term mapping field
|
|
1238
|
+
for (const field of termMappingFields) {
|
|
1239
|
+
if (record[field] && Array.isArray(record[field])) {
|
|
1240
|
+
// Decrement old terms if this is an update
|
|
1241
|
+
if (isUpdate && oldRecord) {
|
|
1242
|
+
// Check if oldRecord has term IDs or terms
|
|
1243
|
+
const termIdsField = `${field}Ids`;
|
|
1244
|
+
if (oldRecord[termIdsField] && Array.isArray(oldRecord[termIdsField])) {
|
|
1245
|
+
// Use term IDs directly for decrementing
|
|
1246
|
+
for (const termId of oldRecord[termIdsField]) {
|
|
1247
|
+
this.termManager.decrementTermCount(termId);
|
|
1248
|
+
}
|
|
1249
|
+
} else if (oldRecord[field] && Array.isArray(oldRecord[field])) {
|
|
1250
|
+
// Use terms to decrement (fallback for backward compatibility)
|
|
1251
|
+
for (const term of oldRecord[field]) {
|
|
1252
|
+
const termId = this.termManager.termToId.get(term);
|
|
1253
|
+
if (termId) {
|
|
1254
|
+
this.termManager.decrementTermCount(termId);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Clear old term IDs if this is an update
|
|
1261
|
+
if (isUpdate) {
|
|
1262
|
+
delete record[`${field}Ids`];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Process new terms - getTermId already increments the count
|
|
1266
|
+
const termIds = [];
|
|
1267
|
+
for (const term of record[field]) {
|
|
1268
|
+
const termId = this.termManager.getTermId(term);
|
|
1269
|
+
termIds.push(termId);
|
|
1270
|
+
}
|
|
1271
|
+
// Store term IDs in the record (for internal use)
|
|
1272
|
+
record[`${field}Ids`] = termIds;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Convert terms to term IDs for serialization (SPACE OPTIMIZATION)
|
|
1279
|
+
* @param {Object} record - Record to process
|
|
1280
|
+
* @returns {Object} - Record with terms converted to term IDs
|
|
1281
|
+
*/
|
|
1282
|
+
removeTermIdsForSerialization(record) {
|
|
1283
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1284
|
+
if (termMappingFields.length === 0 || !this.termManager) {
|
|
1285
|
+
return record;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Create a copy and convert terms to term IDs
|
|
1289
|
+
const optimizedRecord = {
|
|
1290
|
+
...record
|
|
1291
|
+
};
|
|
1292
|
+
for (const field of termMappingFields) {
|
|
1293
|
+
if (optimizedRecord[field] && Array.isArray(optimizedRecord[field])) {
|
|
1294
|
+
// CRITICAL FIX: Only convert if values are strings (terms), skip if already numbers (term IDs)
|
|
1295
|
+
const firstValue = optimizedRecord[field][0];
|
|
1296
|
+
if (typeof firstValue === 'string') {
|
|
1297
|
+
// Convert terms to term IDs for storage
|
|
1298
|
+
optimizedRecord[field] = optimizedRecord[field].map(term => this.termManager.getTermIdWithoutIncrement(term));
|
|
1299
|
+
}
|
|
1300
|
+
// If already numbers (term IDs), leave as-is
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return optimizedRecord;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Convert term IDs back to terms after deserialization (SPACE OPTIMIZATION)
|
|
1308
|
+
* @param {Object} record - Record with term IDs
|
|
1309
|
+
* @returns {Object} - Record with terms restored
|
|
1310
|
+
*/
|
|
1311
|
+
restoreTermIdsAfterDeserialization(record) {
|
|
1312
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1313
|
+
if (termMappingFields.length === 0 || !this.termManager) {
|
|
1314
|
+
return record;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Create a copy and convert term IDs back to terms
|
|
1318
|
+
const restoredRecord = {
|
|
1319
|
+
...record
|
|
1320
|
+
};
|
|
1321
|
+
for (const field of termMappingFields) {
|
|
1322
|
+
if (restoredRecord[field] && Array.isArray(restoredRecord[field])) {
|
|
1323
|
+
// Convert term IDs back to terms for user
|
|
1324
|
+
restoredRecord[field] = restoredRecord[field].map(termId => {
|
|
1325
|
+
const term = this.termManager.idToTerm.get(termId) || termId;
|
|
1326
|
+
return term;
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Remove the *Ids field that was added during serialization
|
|
1331
|
+
const idsFieldName = field + 'Ids';
|
|
1332
|
+
if (restoredRecord[idsFieldName]) {
|
|
1333
|
+
delete restoredRecord[idsFieldName];
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return restoredRecord;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Remove term mapping for a record
|
|
1341
|
+
* @param {Object} record - Record to process
|
|
1342
|
+
*/
|
|
1343
|
+
removeTermMapping(record) {
|
|
1344
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1345
|
+
if (!this.termManager || termMappingFields.length === 0) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Process each term mapping field
|
|
1350
|
+
for (const field of termMappingFields) {
|
|
1351
|
+
// Use terms to decrement (term IDs are not stored in records anymore)
|
|
1352
|
+
if (record[field] && Array.isArray(record[field])) {
|
|
1353
|
+
for (const term of record[field]) {
|
|
1354
|
+
const termId = this.termManager.termToId.get(term);
|
|
1355
|
+
if (termId) {
|
|
1356
|
+
this.termManager.decrementTermCount(termId);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Process term mapping for multiple records in batch (OPTIMIZATION)
|
|
1365
|
+
* @param {Array} records - Records to process
|
|
1366
|
+
* @returns {Array} - Processed records with term mappings
|
|
1367
|
+
*/
|
|
1368
|
+
processTermMappingBatch(records) {
|
|
1369
|
+
const termMappingFields = this.getTermMappingFields();
|
|
1370
|
+
if (!this.termManager || termMappingFields.length === 0 || !records.length) {
|
|
1371
|
+
return records;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// OPTIMIZATION: Pre-collect all unique terms to minimize Map operations
|
|
1375
|
+
const allTerms = new Set();
|
|
1376
|
+
const fieldTerms = new Map(); // field -> Set of terms
|
|
1377
|
+
|
|
1378
|
+
for (const field of termMappingFields) {
|
|
1379
|
+
fieldTerms.set(field, new Set());
|
|
1380
|
+
for (const record of records) {
|
|
1381
|
+
if (record[field] && Array.isArray(record[field])) {
|
|
1382
|
+
for (const term of record[field]) {
|
|
1383
|
+
allTerms.add(term);
|
|
1384
|
+
fieldTerms.get(field).add(term);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// OPTIMIZATION: Batch process all terms at once using bulk operations
|
|
1391
|
+
const termIdMap = new Map();
|
|
1392
|
+
if (this.termManager.bulkGetTermIds) {
|
|
1393
|
+
// Use bulk operation if available
|
|
1394
|
+
const allTermsArray = Array.from(allTerms);
|
|
1395
|
+
const termIds = this.termManager.bulkGetTermIds(allTermsArray);
|
|
1396
|
+
for (let i = 0; i < allTermsArray.length; i++) {
|
|
1397
|
+
termIdMap.set(allTermsArray[i], termIds[i]);
|
|
1398
|
+
}
|
|
1399
|
+
} else {
|
|
1400
|
+
// Fallback to individual operations
|
|
1401
|
+
for (const term of allTerms) {
|
|
1402
|
+
termIdMap.set(term, this.termManager.getTermId(term));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// OPTIMIZATION: Process records using pre-computed term IDs
|
|
1407
|
+
return records.map(record => {
|
|
1408
|
+
const processedRecord = {
|
|
1409
|
+
...record
|
|
1410
|
+
};
|
|
1411
|
+
for (const field of termMappingFields) {
|
|
1412
|
+
if (record[field] && Array.isArray(record[field])) {
|
|
1413
|
+
const termIds = record[field].map(term => termIdMap.get(term));
|
|
1414
|
+
processedRecord[`${field}Ids`] = termIds;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return processedRecord;
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Calculate total size of serialized records (OPTIMIZATION)
|
|
1423
|
+
* @param {Array} records - Records to calculate size for
|
|
1424
|
+
* @returns {number} - Total size in bytes
|
|
1425
|
+
*/
|
|
1426
|
+
calculateBatchSize(records) {
|
|
1427
|
+
if (!records || !records.length) return 0;
|
|
1428
|
+
let totalSize = 0;
|
|
1429
|
+
for (const record of records) {
|
|
1430
|
+
// OPTIMIZATION: Calculate size without creating the actual string
|
|
1431
|
+
// SPACE OPTIMIZATION: Remove term IDs before size calculation
|
|
1432
|
+
const cleanRecord = this.removeTermIdsForSerialization(record);
|
|
1433
|
+
const jsonString = this.serializer.serialize(cleanRecord).toString('utf8');
|
|
1434
|
+
totalSize += Buffer.byteLength(jsonString, 'utf8') + 1; // +1 for newline
|
|
1435
|
+
}
|
|
1436
|
+
return totalSize;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Begin an insert session for batch operations
|
|
1441
|
+
* @param {Object} sessionOptions - Options for the insert session
|
|
1442
|
+
* @returns {InsertSession} - The insert session instance
|
|
1443
|
+
*/
|
|
1444
|
+
beginInsertSession(sessionOptions = {}) {
|
|
1445
|
+
if (this.destroyed) {
|
|
1446
|
+
throw new Error('Database is destroyed');
|
|
1447
|
+
}
|
|
1448
|
+
if (this.closed) {
|
|
1449
|
+
throw new Error('Database is closed. Call init() to reopen it.');
|
|
1450
|
+
}
|
|
1451
|
+
return new InsertSession(this, sessionOptions);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Insert a new record
|
|
1456
|
+
*/
|
|
1457
|
+
async insert(data) {
|
|
1458
|
+
this._validateInitialization('insert');
|
|
1459
|
+
return this.operationQueue.enqueue(async () => {
|
|
1460
|
+
this.isInsideOperationQueue = true;
|
|
1461
|
+
try {
|
|
1462
|
+
// CRITICAL FIX: Validate state before insert operation
|
|
1463
|
+
this.validateState();
|
|
1464
|
+
if (!data || typeof data !== 'object') {
|
|
1465
|
+
throw new Error('Data must be an object');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// CRITICAL FIX: Check abort signal before operation, but allow during destroy cleanup
|
|
1469
|
+
if (this.abortController.signal.aborted && !this.destroying) {
|
|
1470
|
+
throw new Error('Database is destroyed');
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Initialize schema if not already done (auto-detect from first record)
|
|
1474
|
+
if (this.serializer && !this.serializer.schemaManager.isInitialized) {
|
|
1475
|
+
this.serializer.initializeSchema(data, true);
|
|
1476
|
+
if (this.opts.debugMode) {
|
|
1477
|
+
console.log(`🔍 Schema auto-detected from first insert: ${this.serializer.getSchema().join(', ')} [${this.instanceId}]`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// OPTIMIZATION: Process single insert with deferred index updates
|
|
1482
|
+
// CRITICAL FIX: Clone the object to prevent reference modification
|
|
1483
|
+
const clonedData = {
|
|
1484
|
+
...data
|
|
1485
|
+
};
|
|
1486
|
+
const id = clonedData.id || this.generateId();
|
|
1487
|
+
const record = {
|
|
1488
|
+
...data,
|
|
1489
|
+
id
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// OPTIMIZATION: Process term mapping
|
|
1493
|
+
this.processTermMapping(record);
|
|
1494
|
+
if (this.opts.debugMode) {
|
|
1495
|
+
// console.log(`💾 insert(): writeBuffer(before)=${this.writeBuffer.length}`)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Apply schema enforcement - convert to array format and back to enforce schema
|
|
1499
|
+
const schemaEnforcedRecord = this.applySchemaEnforcement(record);
|
|
1500
|
+
|
|
1501
|
+
// Don't store in this.data - only use writeBuffer and index
|
|
1502
|
+
this.writeBuffer.push(schemaEnforcedRecord);
|
|
1503
|
+
if (this.opts.debugMode) {
|
|
1504
|
+
console.log(`🔍 INSERT: Added record to writeBuffer, length now: ${this.writeBuffer.length}`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// OPTIMIZATION: Calculate and store offset and size for writeBuffer record
|
|
1508
|
+
// SPACE OPTIMIZATION: Remove term IDs before serialization
|
|
1509
|
+
const cleanRecord = this.removeTermIdsForSerialization(record);
|
|
1510
|
+
const recordJson = this.serializer.serialize(cleanRecord).toString('utf8');
|
|
1511
|
+
const recordSize = Buffer.byteLength(recordJson, 'utf8');
|
|
1512
|
+
|
|
1513
|
+
// Calculate offset based on end of file + previous writeBuffer sizes
|
|
1514
|
+
const previousWriteBufferSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0);
|
|
1515
|
+
const recordOffset = this.indexOffset + previousWriteBufferSize;
|
|
1516
|
+
this.writeBufferOffsets.push(recordOffset);
|
|
1517
|
+
this.writeBufferSizes.push(recordSize);
|
|
1518
|
+
|
|
1519
|
+
// OPTIMIZATION: Use the current writeBuffer size as the line number (0-based index)
|
|
1520
|
+
const lineNumber = this.writeBuffer.length - 1;
|
|
1521
|
+
|
|
1522
|
+
// OPTIMIZATION: Defer index updates to batch processing
|
|
1523
|
+
// Store the record for batch index processing
|
|
1524
|
+
if (!this.pendingIndexUpdates) {
|
|
1525
|
+
this.pendingIndexUpdates = [];
|
|
1526
|
+
}
|
|
1527
|
+
this.pendingIndexUpdates.push({
|
|
1528
|
+
record,
|
|
1529
|
+
lineNumber
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
// Manual save is now the responsibility of the application
|
|
1533
|
+
this.shouldSave = true;
|
|
1534
|
+
this.performanceStats.operations++;
|
|
1535
|
+
|
|
1536
|
+
// Auto-save manager removed - manual save required
|
|
1537
|
+
|
|
1538
|
+
this.emit('inserted', record);
|
|
1539
|
+
return record;
|
|
1540
|
+
} finally {
|
|
1541
|
+
this.isInsideOperationQueue = false;
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Insert multiple records in batch (OPTIMIZATION)
|
|
1548
|
+
*/
|
|
1549
|
+
async insertBatch(dataArray) {
|
|
1550
|
+
this._validateInitialization('insertBatch');
|
|
1551
|
+
|
|
1552
|
+
// If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
|
|
1553
|
+
if (this.isInsideOperationQueue) {
|
|
1554
|
+
if (this.opts.debugMode) {
|
|
1555
|
+
console.log(`💾 insertBatch inline: insideQueue=${this.isInsideOperationQueue}, size=${Array.isArray(dataArray) ? dataArray.length : 0}`);
|
|
1556
|
+
}
|
|
1557
|
+
return await this._insertBatchInternal(dataArray);
|
|
1558
|
+
}
|
|
1559
|
+
return this.operationQueue.enqueue(async () => {
|
|
1560
|
+
this.isInsideOperationQueue = true;
|
|
1561
|
+
try {
|
|
1562
|
+
if (this.opts.debugMode) {
|
|
1563
|
+
console.log(`💾 insertBatch enqueued: size=${Array.isArray(dataArray) ? dataArray.length : 0}`);
|
|
1564
|
+
}
|
|
1565
|
+
return await this._insertBatchInternal(dataArray);
|
|
1566
|
+
} finally {
|
|
1567
|
+
this.isInsideOperationQueue = false;
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Internal implementation for insertBatch to allow inline execution when already inside the queue
|
|
1574
|
+
*/
|
|
1575
|
+
async _insertBatchInternal(dataArray) {
|
|
1576
|
+
// CRITICAL FIX: Validate state before insert operation
|
|
1577
|
+
this.validateState();
|
|
1578
|
+
if (!Array.isArray(dataArray) || dataArray.length === 0) {
|
|
1579
|
+
throw new Error('DataArray must be a non-empty array');
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// CRITICAL FIX: Check abort signal before operation, but allow during destroy cleanup
|
|
1583
|
+
if (this.abortController.signal.aborted && !this.destroying) {
|
|
1584
|
+
throw new Error('Database is destroyed');
|
|
1585
|
+
}
|
|
1586
|
+
if (this.opts.debugMode) {
|
|
1587
|
+
console.log(`💾 _insertBatchInternal: processing size=${dataArray.length}, startWriteBuffer=${this.writeBuffer.length}`);
|
|
1588
|
+
}
|
|
1589
|
+
const records = [];
|
|
1590
|
+
const startLineNumber = this.writeBuffer.length;
|
|
1591
|
+
|
|
1592
|
+
// Initialize schema if not already done (auto-detect from first record)
|
|
1593
|
+
if (this.serializer && !this.serializer.schemaManager.isInitialized && dataArray.length > 0) {
|
|
1594
|
+
this.serializer.initializeSchema(dataArray[0], true);
|
|
1595
|
+
if (this.opts.debugMode) {
|
|
1596
|
+
console.log(`🔍 Schema auto-detected from first batch insert: ${this.serializer.getSchema().join(', ')} [${this.instanceId}]`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// OPTIMIZATION: Process all records in batch
|
|
1601
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
1602
|
+
const data = dataArray[i];
|
|
1603
|
+
if (!data || typeof data !== 'object') {
|
|
1604
|
+
throw new Error(`Data at index ${i} must be an object`);
|
|
1605
|
+
}
|
|
1606
|
+
const id = data.id || this.generateId();
|
|
1607
|
+
const record = {
|
|
1608
|
+
...data,
|
|
1609
|
+
id
|
|
1610
|
+
};
|
|
1611
|
+
records.push(record);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// OPTIMIZATION: Batch process term mapping
|
|
1615
|
+
const processedRecords = this.processTermMappingBatch(records);
|
|
1616
|
+
|
|
1617
|
+
// Apply schema enforcement to all records
|
|
1618
|
+
const schemaEnforcedRecords = processedRecords.map(record => this.applySchemaEnforcement(record));
|
|
1619
|
+
|
|
1620
|
+
// OPTIMIZATION: Add all records to writeBuffer at once
|
|
1621
|
+
this.writeBuffer.push(...schemaEnforcedRecords);
|
|
1622
|
+
|
|
1623
|
+
// OPTIMIZATION: Calculate offsets and sizes in batch (O(n))
|
|
1624
|
+
let runningTotalSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0);
|
|
1625
|
+
for (let i = 0; i < processedRecords.length; i++) {
|
|
1626
|
+
const record = processedRecords[i];
|
|
1627
|
+
// SPACE OPTIMIZATION: Remove term IDs before serialization
|
|
1628
|
+
const cleanRecord = this.removeTermIdsForSerialization(record);
|
|
1629
|
+
const recordJson = this.serializer.serialize(cleanRecord).toString('utf8');
|
|
1630
|
+
const recordSize = Buffer.byteLength(recordJson, 'utf8');
|
|
1631
|
+
const recordOffset = this.indexOffset + runningTotalSize;
|
|
1632
|
+
runningTotalSize += recordSize;
|
|
1633
|
+
this.writeBufferOffsets.push(recordOffset);
|
|
1634
|
+
this.writeBufferSizes.push(recordSize);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// OPTIMIZATION: Batch process index updates
|
|
1638
|
+
if (!this.pendingIndexUpdates) {
|
|
1639
|
+
this.pendingIndexUpdates = [];
|
|
1640
|
+
}
|
|
1641
|
+
for (let i = 0; i < processedRecords.length; i++) {
|
|
1642
|
+
const lineNumber = startLineNumber + i;
|
|
1643
|
+
this.pendingIndexUpdates.push({
|
|
1644
|
+
record: processedRecords[i],
|
|
1645
|
+
lineNumber
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
this.shouldSave = true;
|
|
1649
|
+
this.performanceStats.operations += processedRecords.length;
|
|
1650
|
+
|
|
1651
|
+
// Emit events for all records
|
|
1652
|
+
if (this.listenerCount('inserted') > 0) {
|
|
1653
|
+
for (const record of processedRecords) {
|
|
1654
|
+
this.emit('inserted', record);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (this.opts.debugMode) {
|
|
1658
|
+
console.log(`💾 _insertBatchInternal: done. added=${processedRecords.length}, writeBuffer=${this.writeBuffer.length}`);
|
|
1659
|
+
}
|
|
1660
|
+
return processedRecords;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Find records matching criteria
|
|
1665
|
+
*/
|
|
1666
|
+
async find(criteria = {}, options = {}) {
|
|
1667
|
+
this._validateInitialization('find');
|
|
1668
|
+
|
|
1669
|
+
// CRITICAL FIX: Validate state before find operation
|
|
1670
|
+
this.validateState();
|
|
1671
|
+
|
|
1672
|
+
// OPTIMIZATION: Find searches writeBuffer directly
|
|
1673
|
+
|
|
1674
|
+
const startTime = Date.now();
|
|
1675
|
+
if (this.opts.debugMode) {
|
|
1676
|
+
console.log(`🔍 FIND START: criteria=${JSON.stringify(criteria)}, writeBuffer=${this.writeBuffer.length}`);
|
|
1677
|
+
}
|
|
1678
|
+
try {
|
|
1679
|
+
// Validate indexed query mode if enabled
|
|
1680
|
+
if (this.opts.indexedQueryMode === 'strict') {
|
|
1681
|
+
this._validateIndexedQuery(criteria);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Get results from file (QueryManager already handles term ID restoration)
|
|
1685
|
+
const fileResultsWithTerms = await this.queryManager.find(criteria, options);
|
|
1686
|
+
|
|
1687
|
+
// Get results from writeBuffer
|
|
1688
|
+
const allPendingRecords = [...this.writeBuffer];
|
|
1689
|
+
const writeBufferResults = this.queryManager.matchesCriteria ? allPendingRecords.filter(record => this.queryManager.matchesCriteria(record, criteria, options)) : allPendingRecords;
|
|
1690
|
+
|
|
1691
|
+
// SPACE OPTIMIZATION: Restore term IDs to terms for writeBuffer results (unless disabled)
|
|
1692
|
+
const writeBufferResultsWithTerms = options.restoreTerms !== false ? writeBufferResults.map(record => this.restoreTermIdsAfterDeserialization(record)) : writeBufferResults;
|
|
1693
|
+
|
|
1694
|
+
// Combine results, removing duplicates (writeBuffer takes precedence)
|
|
1695
|
+
// OPTIMIZATION: Use parallel processing for better performance when writeBuffer has many records
|
|
1696
|
+
let allResults;
|
|
1697
|
+
if (writeBufferResults.length > 50) {
|
|
1698
|
+
// Parallel approach for large writeBuffer
|
|
1699
|
+
const [fileResultsSet, writeBufferSet] = await Promise.all([Promise.resolve(new Set(fileResultsWithTerms.map(r => r.id))), Promise.resolve(new Set(writeBufferResultsWithTerms.map(r => r.id)))]);
|
|
1700
|
+
|
|
1701
|
+
// Merge efficiently: keep file results not in writeBuffer, then add all writeBuffer results
|
|
1702
|
+
const filteredFileResults = await Promise.resolve(fileResultsWithTerms.filter(r => !writeBufferSet.has(r.id)));
|
|
1703
|
+
allResults = [...filteredFileResults, ...writeBufferResultsWithTerms];
|
|
1704
|
+
} else {
|
|
1705
|
+
// Sequential approach for small writeBuffer (original logic)
|
|
1706
|
+
allResults = [...fileResultsWithTerms];
|
|
1707
|
+
|
|
1708
|
+
// Replace file records with writeBuffer records and add new writeBuffer records
|
|
1709
|
+
for (const record of writeBufferResultsWithTerms) {
|
|
1710
|
+
const existingIndex = allResults.findIndex(r => r.id === record.id);
|
|
1711
|
+
if (existingIndex !== -1) {
|
|
1712
|
+
// Replace existing record with writeBuffer version
|
|
1713
|
+
allResults[existingIndex] = record;
|
|
1714
|
+
} else {
|
|
1715
|
+
// Add new record from writeBuffer
|
|
1716
|
+
allResults.push(record);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Remove records that are marked as deleted
|
|
1722
|
+
const finalResults = allResults.filter(record => !this.deletedIds.has(record.id));
|
|
1723
|
+
if (this.opts.debugMode) {
|
|
1724
|
+
console.log(`🔍 Database.find returning: ${finalResults?.length || 0} records (${fileResultsWithTerms.length} from file, ${writeBufferResults.length} from writeBuffer, ${this.deletedIds.size} deleted), type: ${typeof finalResults}, isArray: ${Array.isArray(finalResults)}`);
|
|
1725
|
+
}
|
|
1726
|
+
this.performanceStats.queryTime += Date.now() - startTime;
|
|
1727
|
+
return finalResults;
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
// Don't log expected errors in strict mode or for array field validation
|
|
1730
|
+
if (this.opts.indexedQueryMode !== 'strict' || !error.message.includes('Strict indexed mode')) {
|
|
1731
|
+
// Don't log errors for array field validation as they are expected
|
|
1732
|
+
if (!error.message.includes('Invalid query for array field')) {
|
|
1733
|
+
console.error('Query failed:', error);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
throw error;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Validate indexed query mode for strict mode
|
|
1742
|
+
* @private
|
|
1743
|
+
*/
|
|
1744
|
+
_validateIndexedQuery(criteria) {
|
|
1745
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
1746
|
+
return; // Allow null/undefined criteria
|
|
1747
|
+
}
|
|
1748
|
+
const indexedFields = Object.keys(this.opts.indexes || {});
|
|
1749
|
+
if (indexedFields.length === 0) {
|
|
1750
|
+
return; // No indexed fields, allow all queries
|
|
1751
|
+
}
|
|
1752
|
+
const queryFields = this._extractQueryFields(criteria);
|
|
1753
|
+
const nonIndexedFields = queryFields.filter(field => !indexedFields.includes(field));
|
|
1754
|
+
if (nonIndexedFields.length > 0) {
|
|
1755
|
+
const availableFields = indexedFields.length > 0 ? indexedFields.join(', ') : 'none';
|
|
1756
|
+
if (nonIndexedFields.length === 1) {
|
|
1757
|
+
throw new Error(`Strict indexed mode: Field '${nonIndexedFields[0]}' is not indexed. Available indexed fields: ${availableFields}`);
|
|
1758
|
+
} else {
|
|
1759
|
+
throw new Error(`Strict indexed mode: Fields '${nonIndexedFields.join("', '")}' are not indexed. Available indexed fields: ${availableFields}`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Create a shallow copy of a record for change detection
|
|
1766
|
+
* Optimized for known field types: number, string, null, or single-level arrays
|
|
1767
|
+
* @private
|
|
1768
|
+
*/
|
|
1769
|
+
_createShallowCopy(record) {
|
|
1770
|
+
const copy = {};
|
|
1771
|
+
// Use for...in loop for better performance
|
|
1772
|
+
for (const key in record) {
|
|
1773
|
+
const value = record[key];
|
|
1774
|
+
// Optimize for common types first
|
|
1775
|
+
if (value === null || typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
1776
|
+
copy[key] = value;
|
|
1777
|
+
} else if (Array.isArray(value)) {
|
|
1778
|
+
// Only copy if array has elements and is not empty
|
|
1779
|
+
copy[key] = value.length > 0 ? value.slice() : [];
|
|
1780
|
+
} else if (typeof value === 'object') {
|
|
1781
|
+
// For complex objects, use shallow copy
|
|
1782
|
+
copy[key] = {
|
|
1783
|
+
...value
|
|
1784
|
+
};
|
|
1785
|
+
} else {
|
|
1786
|
+
copy[key] = value;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return copy;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Create an intuitive API wrapper using a class with Proxy
|
|
1794
|
+
* Combines the benefits of classes with the flexibility of Proxy
|
|
1795
|
+
* @private
|
|
1796
|
+
*/
|
|
1797
|
+
_createEntryProxy(entry, originalRecord) {
|
|
1798
|
+
// Create a class instance that wraps the entry
|
|
1799
|
+
const iterateEntry = new IterateEntry(entry, originalRecord);
|
|
1800
|
+
|
|
1801
|
+
// Create a lightweight proxy that only intercepts property access
|
|
1802
|
+
return new Proxy(iterateEntry, {
|
|
1803
|
+
get(target, property) {
|
|
1804
|
+
// Handle special methods
|
|
1805
|
+
if (property === 'delete') {
|
|
1806
|
+
return () => target.delete();
|
|
1807
|
+
}
|
|
1808
|
+
if (property === 'value') {
|
|
1809
|
+
return target.value;
|
|
1810
|
+
}
|
|
1811
|
+
if (property === 'isModified') {
|
|
1812
|
+
return target.isModified;
|
|
1813
|
+
}
|
|
1814
|
+
if (property === 'isMarkedForDeletion') {
|
|
1815
|
+
return target.isMarkedForDeletion;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// For all other properties, return from the underlying entry
|
|
1819
|
+
return target._entry[property];
|
|
1820
|
+
},
|
|
1821
|
+
set(target, property, value) {
|
|
1822
|
+
// Set the value in the underlying entry
|
|
1823
|
+
target._entry[property] = value;
|
|
1824
|
+
target._modified = true;
|
|
1825
|
+
return true;
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Create a high-performance wrapper for maximum speed
|
|
1832
|
+
* @private
|
|
1833
|
+
*/
|
|
1834
|
+
_createHighPerformanceWrapper(entry, originalRecord) {
|
|
1835
|
+
// Create a simple wrapper object for high performance
|
|
1836
|
+
const wrapper = {
|
|
1837
|
+
value: entry,
|
|
1838
|
+
delete: () => {
|
|
1839
|
+
entry._markedForDeletion = true;
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// Mark for change tracking
|
|
1845
|
+
entry._modified = false;
|
|
1846
|
+
entry._markedForDeletion = false;
|
|
1847
|
+
return wrapper;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Check if a record has changed using optimized comparison
|
|
1852
|
+
* Optimized for known field types: number, string, null, or single-level arrays
|
|
1853
|
+
* @private
|
|
1854
|
+
*/
|
|
1855
|
+
_hasRecordChanged(current, original) {
|
|
1856
|
+
// Quick reference check first
|
|
1857
|
+
if (current === original) return false;
|
|
1858
|
+
|
|
1859
|
+
// Compare each field - optimized for common types
|
|
1860
|
+
for (const key in current) {
|
|
1861
|
+
const currentValue = current[key];
|
|
1862
|
+
const originalValue = original[key];
|
|
1863
|
+
|
|
1864
|
+
// Quick reference check (most common case)
|
|
1865
|
+
if (currentValue === originalValue) continue;
|
|
1866
|
+
|
|
1867
|
+
// Handle null values
|
|
1868
|
+
if (currentValue === null || originalValue === null) {
|
|
1869
|
+
if (currentValue !== originalValue) return true;
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Handle primitive types (number, string, boolean) - most common
|
|
1874
|
+
const currentType = typeof currentValue;
|
|
1875
|
+
if (currentType === 'number' || currentType === 'string' || currentType === 'boolean') {
|
|
1876
|
+
if (currentType !== typeof originalValue || currentValue !== originalValue) return true;
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Handle arrays (single-level) - second most common
|
|
1881
|
+
if (Array.isArray(currentValue)) {
|
|
1882
|
+
if (!Array.isArray(originalValue) || currentValue.length !== originalValue.length) return true;
|
|
1883
|
+
|
|
1884
|
+
// Fast array comparison for primitive types
|
|
1885
|
+
for (let i = 0; i < currentValue.length; i++) {
|
|
1886
|
+
if (currentValue[i] !== originalValue[i]) return true;
|
|
1887
|
+
}
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Handle objects (shallow comparison only) - least common
|
|
1892
|
+
if (currentType === 'object') {
|
|
1893
|
+
if (typeof originalValue !== 'object') return true;
|
|
1894
|
+
|
|
1895
|
+
// Fast object comparison using for...in
|
|
1896
|
+
for (const objKey in currentValue) {
|
|
1897
|
+
if (currentValue[objKey] !== originalValue[objKey]) return true;
|
|
1898
|
+
}
|
|
1899
|
+
// Check if original has extra keys
|
|
1900
|
+
for (const objKey in originalValue) {
|
|
1901
|
+
if (!(objKey in currentValue)) return true;
|
|
1902
|
+
}
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// Fallback for other types
|
|
1907
|
+
if (currentValue !== originalValue) return true;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Check if original has extra keys (only if we haven't found differences yet)
|
|
1911
|
+
for (const key in original) {
|
|
1912
|
+
if (!(key in current)) return true;
|
|
1913
|
+
}
|
|
1914
|
+
return false;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
/**
|
|
1918
|
+
* Extract field names from query criteria
|
|
1919
|
+
* @private
|
|
1920
|
+
*/
|
|
1921
|
+
_extractQueryFields(criteria) {
|
|
1922
|
+
const fields = new Set();
|
|
1923
|
+
const extractFromObject = obj => {
|
|
1924
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1925
|
+
if (key.startsWith('$')) {
|
|
1926
|
+
// Handle logical operators
|
|
1927
|
+
if (Array.isArray(value)) {
|
|
1928
|
+
value.forEach(item => {
|
|
1929
|
+
if (typeof item === 'object' && item !== null) {
|
|
1930
|
+
extractFromObject(item);
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
1934
|
+
extractFromObject(value);
|
|
1935
|
+
}
|
|
1936
|
+
} else {
|
|
1937
|
+
// Regular field
|
|
1938
|
+
fields.add(key);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
extractFromObject(criteria);
|
|
1943
|
+
return Array.from(fields);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* Update records matching criteria
|
|
1948
|
+
*/
|
|
1949
|
+
async update(criteria, updateData) {
|
|
1950
|
+
this._validateInitialization('update');
|
|
1951
|
+
return this.operationQueue.enqueue(async () => {
|
|
1952
|
+
this.isInsideOperationQueue = true;
|
|
1953
|
+
try {
|
|
1954
|
+
const startTime = Date.now();
|
|
1955
|
+
if (this.opts.debugMode) {
|
|
1956
|
+
console.log(`🔄 UPDATE START: criteria=${JSON.stringify(criteria)}, updateData=${JSON.stringify(updateData)}`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// CRITICAL FIX: Validate state before update operation
|
|
1960
|
+
this.validateState();
|
|
1961
|
+
|
|
1962
|
+
// CRITICAL FIX: If there's data to save, call save() to persist it
|
|
1963
|
+
// Only save if there are actual records in writeBuffer
|
|
1964
|
+
if (this.shouldSave && this.writeBuffer.length > 0) {
|
|
1965
|
+
if (this.opts.debugMode) {
|
|
1966
|
+
console.log(`🔄 UPDATE: Calling save() before update - writeBuffer.length=${this.writeBuffer.length}`);
|
|
1967
|
+
}
|
|
1968
|
+
const saveStart = Date.now();
|
|
1969
|
+
await this.save(false); // Use save(false) since we're already in queue
|
|
1970
|
+
if (this.opts.debugMode) {
|
|
1971
|
+
console.log(`🔄 UPDATE: Save completed in ${Date.now() - saveStart}ms`);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (this.opts.debugMode) {
|
|
1975
|
+
console.log(`🔄 UPDATE: Starting find() - writeBuffer=${this.writeBuffer.length}`);
|
|
1976
|
+
}
|
|
1977
|
+
const findStart = Date.now();
|
|
1978
|
+
// CRITICAL FIX: Get raw records without term restoration for update operations
|
|
1979
|
+
const records = await this.find(criteria, {
|
|
1980
|
+
restoreTerms: false
|
|
1981
|
+
});
|
|
1982
|
+
if (this.opts.debugMode) {
|
|
1983
|
+
console.log(`🔄 UPDATE: Find completed in ${Date.now() - findStart}ms, found ${records.length} records`);
|
|
1984
|
+
}
|
|
1985
|
+
const updatedRecords = [];
|
|
1986
|
+
for (const record of records) {
|
|
1987
|
+
const recordStart = Date.now();
|
|
1988
|
+
if (this.opts.debugMode) {
|
|
1989
|
+
console.log(`🔄 UPDATE: Processing record ${record.id}`);
|
|
1990
|
+
}
|
|
1991
|
+
const updated = {
|
|
1992
|
+
...record,
|
|
1993
|
+
...updateData
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
// Process term mapping for update
|
|
1997
|
+
const termMappingStart = Date.now();
|
|
1998
|
+
this.processTermMapping(updated, true, record);
|
|
1999
|
+
if (this.opts.debugMode) {
|
|
2000
|
+
console.log(`🔄 UPDATE: Term mapping completed in ${Date.now() - termMappingStart}ms`);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// CRITICAL FIX: Remove old terms from index before adding new ones
|
|
2004
|
+
if (this.indexManager) {
|
|
2005
|
+
await this.indexManager.remove(record);
|
|
2006
|
+
if (this.opts.debugMode) {
|
|
2007
|
+
console.log(`🔄 UPDATE: Removed old terms from index for record ${record.id}`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// Update record in writeBuffer or add to writeBuffer if not present
|
|
2012
|
+
const index = this.writeBuffer.findIndex(r => r.id === record.id);
|
|
2013
|
+
let lineNumber = null;
|
|
2014
|
+
if (index !== -1) {
|
|
2015
|
+
// Record is already in writeBuffer, update it
|
|
2016
|
+
this.writeBuffer[index] = updated;
|
|
2017
|
+
lineNumber = index;
|
|
2018
|
+
if (this.opts.debugMode) {
|
|
2019
|
+
console.log(`🔄 UPDATE: Updated existing writeBuffer record at index ${index}`);
|
|
2020
|
+
}
|
|
2021
|
+
} else {
|
|
2022
|
+
// Record is in file, add updated version to writeBuffer
|
|
2023
|
+
// This will ensure the updated record is saved and replaces the file version
|
|
2024
|
+
this.writeBuffer.push(updated);
|
|
2025
|
+
lineNumber = this.writeBuffer.length - 1;
|
|
2026
|
+
if (this.opts.debugMode) {
|
|
2027
|
+
console.log(`🔄 UPDATE: Added new record to writeBuffer at index ${lineNumber}`);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
const indexUpdateStart = Date.now();
|
|
2031
|
+
await this.indexManager.update(record, updated, lineNumber);
|
|
2032
|
+
if (this.opts.debugMode) {
|
|
2033
|
+
console.log(`🔄 UPDATE: Index update completed in ${Date.now() - indexUpdateStart}ms`);
|
|
2034
|
+
}
|
|
2035
|
+
updatedRecords.push(updated);
|
|
2036
|
+
if (this.opts.debugMode) {
|
|
2037
|
+
console.log(`🔄 UPDATE: Record ${record.id} completed in ${Date.now() - recordStart}ms`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
this.shouldSave = true;
|
|
2041
|
+
this.performanceStats.operations++;
|
|
2042
|
+
if (this.opts.debugMode) {
|
|
2043
|
+
console.log(`🔄 UPDATE COMPLETED: ${updatedRecords.length} records updated in ${Date.now() - startTime}ms`);
|
|
2044
|
+
}
|
|
2045
|
+
this.emit('updated', updatedRecords);
|
|
2046
|
+
return updatedRecords;
|
|
2047
|
+
} finally {
|
|
2048
|
+
this.isInsideOperationQueue = false;
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/**
|
|
2054
|
+
* Delete records matching criteria
|
|
2055
|
+
*/
|
|
2056
|
+
async delete(criteria) {
|
|
2057
|
+
this._validateInitialization('delete');
|
|
2058
|
+
return this.operationQueue.enqueue(async () => {
|
|
2059
|
+
this.isInsideOperationQueue = true;
|
|
2060
|
+
try {
|
|
2061
|
+
// CRITICAL FIX: Validate state before delete operation
|
|
2062
|
+
this.validateState();
|
|
2063
|
+
const records = await this.find(criteria);
|
|
2064
|
+
const deletedIds = [];
|
|
2065
|
+
if (this.opts.debugMode) {
|
|
2066
|
+
console.log(`🗑️ Delete operation: found ${records.length} records to delete`);
|
|
2067
|
+
console.log(`🗑️ Records to delete:`, records.map(r => ({
|
|
2068
|
+
id: r.id,
|
|
2069
|
+
name: r.name
|
|
2070
|
+
})));
|
|
2071
|
+
console.log(`🗑️ Current writeBuffer length: ${this.writeBuffer.length}`);
|
|
2072
|
+
console.log(`🗑️ Current deletedIds:`, Array.from(this.deletedIds));
|
|
2073
|
+
}
|
|
2074
|
+
for (const record of records) {
|
|
2075
|
+
// Remove term mapping
|
|
2076
|
+
this.removeTermMapping(record);
|
|
2077
|
+
await this.indexManager.remove(record);
|
|
2078
|
+
|
|
2079
|
+
// Remove record from writeBuffer or mark as deleted
|
|
2080
|
+
const index = this.writeBuffer.findIndex(r => r.id === record.id);
|
|
2081
|
+
if (index !== -1) {
|
|
2082
|
+
this.writeBuffer.splice(index, 1);
|
|
2083
|
+
if (this.opts.debugMode) {
|
|
2084
|
+
console.log(`🗑️ Removed record ${record.id} from writeBuffer`);
|
|
2085
|
+
}
|
|
2086
|
+
} else {
|
|
2087
|
+
// If record is not in writeBuffer (was saved), mark it as deleted
|
|
2088
|
+
this.deletedIds.add(record.id);
|
|
2089
|
+
if (this.opts.debugMode) {
|
|
2090
|
+
console.log(`🗑️ Marked record ${record.id} as deleted (not in writeBuffer)`);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
deletedIds.push(record.id);
|
|
2094
|
+
}
|
|
2095
|
+
if (this.opts.debugMode) {
|
|
2096
|
+
console.log(`🗑️ After delete: writeBuffer length: ${this.writeBuffer.length}, deletedIds:`, Array.from(this.deletedIds));
|
|
2097
|
+
}
|
|
2098
|
+
this.shouldSave = true;
|
|
2099
|
+
this.performanceStats.operations++;
|
|
2100
|
+
this.emit('deleted', deletedIds);
|
|
2101
|
+
return deletedIds;
|
|
2102
|
+
} finally {
|
|
2103
|
+
this.isInsideOperationQueue = false;
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Generate a unique ID
|
|
2110
|
+
*/
|
|
2111
|
+
generateId() {
|
|
2112
|
+
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* Apply schema enforcement to a record
|
|
2117
|
+
* Converts object to array and back to enforce schema (remove extra fields, add undefined for missing fields)
|
|
2118
|
+
*/
|
|
2119
|
+
applySchemaEnforcement(record) {
|
|
2120
|
+
// Only apply schema enforcement if fields configuration is explicitly provided
|
|
2121
|
+
if (!this.opts.fields) {
|
|
2122
|
+
return record; // No schema enforcement without explicit fields configuration
|
|
2123
|
+
}
|
|
2124
|
+
if (!this.serializer || !this.serializer.schemaManager || !this.serializer.schemaManager.isInitialized) {
|
|
2125
|
+
return record; // No schema enforcement if schema not initialized
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Convert to array format (enforces schema)
|
|
2129
|
+
const arrayFormat = this.serializer.convertToArrayFormat(record);
|
|
2130
|
+
|
|
2131
|
+
// Convert back to object (only schema fields will be present)
|
|
2132
|
+
const enforcedRecord = this.serializer.convertFromArrayFormat(arrayFormat);
|
|
2133
|
+
|
|
2134
|
+
// Preserve the ID if it exists
|
|
2135
|
+
if (record.id) {
|
|
2136
|
+
enforcedRecord.id = record.id;
|
|
2137
|
+
}
|
|
2138
|
+
return enforcedRecord;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/**
|
|
2142
|
+
* Initialize schema for array-based serialization
|
|
2143
|
+
*/
|
|
2144
|
+
initializeSchema() {
|
|
2145
|
+
if (!this.serializer || !this.serializer.schemaManager) {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Try to get schema from options first
|
|
2150
|
+
if (this.opts.schema && Array.isArray(this.opts.schema)) {
|
|
2151
|
+
this.serializer.initializeSchema(this.opts.schema);
|
|
2152
|
+
if (this.opts.debugMode) {
|
|
2153
|
+
console.log(`🔍 Schema initialized from options: ${this.opts.schema.join(', ')} [${this.instanceId}]`);
|
|
2154
|
+
}
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Try to initialize from fields configuration (new format)
|
|
2159
|
+
if (this.opts.fields && typeof this.opts.fields === 'object') {
|
|
2160
|
+
const fieldNames = Object.keys(this.opts.fields);
|
|
2161
|
+
if (fieldNames.length > 0) {
|
|
2162
|
+
this.serializer.initializeSchema(fieldNames);
|
|
2163
|
+
if (this.opts.debugMode) {
|
|
2164
|
+
console.log(`🔍 Schema initialized from fields: ${fieldNames.join(', ')} [${this.instanceId}]`);
|
|
2165
|
+
}
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// Try to auto-detect schema from existing data
|
|
2171
|
+
if (this.data && this.data.length > 0) {
|
|
2172
|
+
this.serializer.initializeSchema(this.data, true); // autoDetect = true
|
|
2173
|
+
if (this.opts.debugMode) {
|
|
2174
|
+
console.log(`🔍 Schema auto-detected from data: ${this.serializer.getSchema().join(', ')} [${this.instanceId}]`);
|
|
2175
|
+
}
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// CRITICAL FIX: Don't initialize schema from indexes
|
|
2180
|
+
// This was causing data loss because only indexed fields were preserved
|
|
2181
|
+
// Let schema be auto-detected from actual data instead
|
|
2182
|
+
|
|
2183
|
+
if (this.opts.debugMode) {
|
|
2184
|
+
console.log(`🔍 No schema initialization possible - will auto-detect on first insert [${this.instanceId}]`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Get database length (number of records)
|
|
2190
|
+
*/
|
|
2191
|
+
get length() {
|
|
2192
|
+
// Return total records: writeBuffer + saved records
|
|
2193
|
+
// writeBuffer contains unsaved records
|
|
2194
|
+
// For saved records, use the length of offsets array (number of saved records)
|
|
2195
|
+
const savedRecords = this.offsets.length;
|
|
2196
|
+
const writeBufferRecords = this.writeBuffer.length;
|
|
2197
|
+
|
|
2198
|
+
// CRITICAL FIX: Validate that offsets array is consistent with actual data
|
|
2199
|
+
// This prevents the bug where database reassignment causes desynchronization
|
|
2200
|
+
if (this.initialized && savedRecords > 0) {
|
|
2201
|
+
try {
|
|
2202
|
+
// Check if the offsets array is consistent with the actual file
|
|
2203
|
+
// If offsets exist but file is empty or corrupted, reset offsets
|
|
2204
|
+
if (this.fileHandler && this.fileHandler.file) {
|
|
2205
|
+
try {
|
|
2206
|
+
// Use synchronous file stats to check if file is empty
|
|
2207
|
+
const stats = _fs.default.statSync(this.fileHandler.file);
|
|
2208
|
+
if (stats && stats.size === 0 && savedRecords > 0) {
|
|
2209
|
+
// File is empty but offsets array has records - this is the bug condition
|
|
2210
|
+
if (this.opts.debugMode) {
|
|
2211
|
+
console.log(`🔧 LENGTH FIX: Detected desynchronized offsets (${savedRecords} records) with empty file, resetting offsets`);
|
|
2212
|
+
}
|
|
2213
|
+
this.offsets = [];
|
|
2214
|
+
return writeBufferRecords; // Return only writeBuffer records
|
|
2215
|
+
}
|
|
2216
|
+
} catch (fileError) {
|
|
2217
|
+
// File doesn't exist or can't be read - reset offsets
|
|
2218
|
+
if (savedRecords > 0) {
|
|
2219
|
+
if (this.opts.debugMode) {
|
|
2220
|
+
console.log(`🔧 LENGTH FIX: File doesn't exist but offsets array has ${savedRecords} records, resetting offsets`);
|
|
2221
|
+
}
|
|
2222
|
+
this.offsets = [];
|
|
2223
|
+
return writeBufferRecords;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
} catch (error) {
|
|
2228
|
+
// If we can't validate, fall back to the original behavior
|
|
2229
|
+
if (this.opts.debugMode) {
|
|
2230
|
+
console.log(`🔧 LENGTH FIX: Could not validate offsets, using original calculation: ${error.message}`);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return writeBufferRecords + savedRecords;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Calculate current writeBuffer size in bytes (similar to published v1.1.0)
|
|
2239
|
+
*/
|
|
2240
|
+
currentWriteBufferSize() {
|
|
2241
|
+
if (!this.writeBuffer || this.writeBuffer.length === 0) {
|
|
2242
|
+
return 0;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Calculate total size of all records in writeBuffer
|
|
2246
|
+
let totalSize = 0;
|
|
2247
|
+
for (const record of this.writeBuffer) {
|
|
2248
|
+
if (record) {
|
|
2249
|
+
// SPACE OPTIMIZATION: Remove term IDs before size calculation
|
|
2250
|
+
const cleanRecord = this.removeTermIdsForSerialization(record);
|
|
2251
|
+
const recordJson = JSON.stringify(cleanRecord) + '\n';
|
|
2252
|
+
totalSize += Buffer.byteLength(recordJson, 'utf8');
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
return totalSize;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* Get database statistics
|
|
2260
|
+
*/
|
|
2261
|
+
getStats() {
|
|
2262
|
+
const stats = {
|
|
2263
|
+
records: this.writeBuffer.length,
|
|
2264
|
+
writeBufferSize: this.currentWriteBufferSize(),
|
|
2265
|
+
maxMemoryUsage: this.opts.maxMemoryUsage,
|
|
2266
|
+
performance: this.performanceStats,
|
|
2267
|
+
lastSave: this.lastSaveTime,
|
|
2268
|
+
shouldSave: this.shouldSave,
|
|
2269
|
+
initialized: this.initialized
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
// Add term mapping stats if enabled
|
|
2273
|
+
if (this.opts.termMapping && this.termManager) {
|
|
2274
|
+
stats.termMapping = this.termManager.getStats();
|
|
2275
|
+
}
|
|
2276
|
+
return stats;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Initialize database (alias for initialize for backward compatibility)
|
|
2281
|
+
*/
|
|
2282
|
+
async init() {
|
|
2283
|
+
return this.initialize();
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Destroy database - DESTRUCTIVE MODE
|
|
2288
|
+
* Assumes save() has already been called by user
|
|
2289
|
+
* If anything is still active, it indicates a bug - log error and force cleanup
|
|
2290
|
+
*/
|
|
2291
|
+
async destroy() {
|
|
2292
|
+
if (this.destroyed) return;
|
|
2293
|
+
|
|
2294
|
+
// Mark as destroying immediately to prevent new operations
|
|
2295
|
+
this.destroying = true;
|
|
2296
|
+
|
|
2297
|
+
// Wait for all active insert sessions to complete before destroying
|
|
2298
|
+
if (this.activeInsertSessions.size > 0) {
|
|
2299
|
+
if (this.opts.debugMode) {
|
|
2300
|
+
console.log(`⏳ destroy: Waiting for ${this.activeInsertSessions.size} active insert sessions`);
|
|
2301
|
+
}
|
|
2302
|
+
const sessionPromises = Array.from(this.activeInsertSessions).map(session => session.waitForOperations(null) // Wait indefinitely for sessions to complete
|
|
2303
|
+
);
|
|
2304
|
+
try {
|
|
2305
|
+
await Promise.all(sessionPromises);
|
|
2306
|
+
} catch (error) {
|
|
2307
|
+
if (this.opts.debugMode) {
|
|
2308
|
+
console.log(`⚠️ destroy: Error waiting for sessions: ${error.message}`);
|
|
2309
|
+
}
|
|
2310
|
+
// Continue with destruction even if sessions have issues
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// Destroy all active sessions
|
|
2314
|
+
for (const session of this.activeInsertSessions) {
|
|
2315
|
+
session.destroy();
|
|
2316
|
+
}
|
|
2317
|
+
this.activeInsertSessions.clear();
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// CRITICAL FIX: Add timeout protection to prevent destroy() from hanging
|
|
2321
|
+
const destroyPromise = this._performDestroy();
|
|
2322
|
+
let timeoutHandle = null;
|
|
2323
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2324
|
+
timeoutHandle = setTimeout(() => {
|
|
2325
|
+
reject(new Error('Destroy operation timed out after 5 seconds'));
|
|
2326
|
+
}, 5000);
|
|
2327
|
+
});
|
|
2328
|
+
try {
|
|
2329
|
+
await Promise.race([destroyPromise, timeoutPromise]);
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
if (error.message === 'Destroy operation timed out after 5 seconds') {
|
|
2332
|
+
console.error('🚨 DESTROY TIMEOUT: Force destroying database after timeout');
|
|
2333
|
+
// Force mark as destroyed even if cleanup failed
|
|
2334
|
+
this.destroyed = true;
|
|
2335
|
+
this.destroying = false;
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
throw error;
|
|
2339
|
+
} finally {
|
|
2340
|
+
// Clear the timeout to prevent Jest open handle warning
|
|
2341
|
+
if (timeoutHandle) {
|
|
2342
|
+
clearTimeout(timeoutHandle);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* Internal destroy implementation
|
|
2349
|
+
*/
|
|
2350
|
+
async _performDestroy() {
|
|
2351
|
+
try {
|
|
2352
|
+
// CRITICAL: Check for bugs - anything active indicates save() didn't work properly
|
|
2353
|
+
const bugs = [];
|
|
2354
|
+
|
|
2355
|
+
// Check for pending data that should have been saved
|
|
2356
|
+
if (this.writeBuffer.length > 0) {
|
|
2357
|
+
const bug = `BUG: writeBuffer has ${this.writeBuffer.length} records - save() should have cleared this`;
|
|
2358
|
+
bugs.push(bug);
|
|
2359
|
+
console.error(`🚨 ${bug}`);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Check for pending operations that should have completed
|
|
2363
|
+
if (this.pendingOperations.size > 0) {
|
|
2364
|
+
const bug = `BUG: ${this.pendingOperations.size} pending operations - save() should have completed these`;
|
|
2365
|
+
bugs.push(bug);
|
|
2366
|
+
console.error(`🚨 ${bug}`);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Auto-save manager removed - no cleanup needed
|
|
2370
|
+
|
|
2371
|
+
// Check for active save operation
|
|
2372
|
+
if (this.isSaving) {
|
|
2373
|
+
const bug = `BUG: save operation still active - previous save() should have completed`;
|
|
2374
|
+
bugs.push(bug);
|
|
2375
|
+
console.error(`🚨 ${bug}`);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// If bugs detected, throw error with details
|
|
2379
|
+
if (bugs.length > 0) {
|
|
2380
|
+
const errorMessage = `Database destroy() found ${bugs.length} bug(s) - save() did not complete properly:\n${bugs.join('\n')}`;
|
|
2381
|
+
console.error(`🚨 DESTROY ERROR: ${errorMessage}`);
|
|
2382
|
+
throw new Error(errorMessage);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// FORCE DESTRUCTIVE CLEANUP - no waiting, no graceful shutdown
|
|
2386
|
+
if (this.opts.debugMode) {
|
|
2387
|
+
console.log('💥 DESTRUCTIVE DESTROY: Force cleaning up all resources');
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// Cancel all operations immediately
|
|
2391
|
+
this.abortController.abort();
|
|
2392
|
+
|
|
2393
|
+
// Auto-save removed - no cleanup needed
|
|
2394
|
+
|
|
2395
|
+
// Clear all buffers and data structures
|
|
2396
|
+
this.writeBuffer = [];
|
|
2397
|
+
this.writeBufferOffsets = [];
|
|
2398
|
+
this.writeBufferSizes = [];
|
|
2399
|
+
this.deletedIds.clear();
|
|
2400
|
+
this.pendingOperations.clear();
|
|
2401
|
+
this.pendingIndexUpdates = [];
|
|
2402
|
+
|
|
2403
|
+
// Force close file handlers
|
|
2404
|
+
if (this.fileHandler) {
|
|
2405
|
+
try {
|
|
2406
|
+
// Force close any open file descriptors
|
|
2407
|
+
await this.fileHandler.close?.();
|
|
2408
|
+
} catch (error) {
|
|
2409
|
+
// Ignore file close errors during destructive cleanup
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Clear all managers
|
|
2414
|
+
if (this.indexManager) {
|
|
2415
|
+
this.indexManager.clear?.();
|
|
2416
|
+
}
|
|
2417
|
+
if (this.termManager) {
|
|
2418
|
+
this.termManager.clear?.();
|
|
2419
|
+
}
|
|
2420
|
+
if (this.queryManager) {
|
|
2421
|
+
this.queryManager.clear?.();
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// Clear operation queue
|
|
2425
|
+
if (this.operationQueue) {
|
|
2426
|
+
this.operationQueue.clear?.();
|
|
2427
|
+
this.operationQueue = null;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Mark as destroyed
|
|
2431
|
+
this.destroyed = true;
|
|
2432
|
+
this.destroying = false;
|
|
2433
|
+
if (this.opts.debugMode) {
|
|
2434
|
+
console.log('💥 DESTRUCTIVE DESTROY: Database completely destroyed');
|
|
2435
|
+
}
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
// Even if cleanup fails, mark as destroyed
|
|
2438
|
+
this.destroyed = true;
|
|
2439
|
+
this.destroying = false;
|
|
2440
|
+
|
|
2441
|
+
// Re-throw the error so user knows about the bug
|
|
2442
|
+
throw error;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* Find one record
|
|
2448
|
+
*/
|
|
2449
|
+
async findOne(criteria, options = {}) {
|
|
2450
|
+
this._validateInitialization('findOne');
|
|
2451
|
+
const results = await this.find(criteria, {
|
|
2452
|
+
...options,
|
|
2453
|
+
limit: 1
|
|
2454
|
+
});
|
|
2455
|
+
return results.length > 0 ? results[0] : null;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* Count records
|
|
2460
|
+
*/
|
|
2461
|
+
async count(criteria = {}, options = {}) {
|
|
2462
|
+
this._validateInitialization('count');
|
|
2463
|
+
const results = await this.find(criteria, options);
|
|
2464
|
+
return results.length;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/**
|
|
2468
|
+
* Wait for all pending operations to complete
|
|
2469
|
+
*/
|
|
2470
|
+
async _waitForPendingOperations() {
|
|
2471
|
+
if (this.operationQueue && this.operationQueue.getQueueLength() > 0) {
|
|
2472
|
+
if (this.opts.debugMode) {
|
|
2473
|
+
console.log('💾 Save: Waiting for pending operations to complete');
|
|
2474
|
+
}
|
|
2475
|
+
// CRITICAL FIX: Wait without timeout to ensure all operations complete
|
|
2476
|
+
// This prevents race conditions and data loss
|
|
2477
|
+
await this.operationQueue.waitForCompletion(null);
|
|
2478
|
+
|
|
2479
|
+
// Verify queue is actually empty
|
|
2480
|
+
if (this.operationQueue.getQueueLength() > 0) {
|
|
2481
|
+
throw new Error('Operation queue not empty after wait');
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Flush write buffer completely with smart detection of ongoing insertions
|
|
2488
|
+
*/
|
|
2489
|
+
async _flushWriteBufferCompletely() {
|
|
2490
|
+
// Force complete flush of write buffer with intelligent detection
|
|
2491
|
+
let attempts = 0;
|
|
2492
|
+
const maxStuckAttempts = 5; // Maximum attempts with identical data (only protection against infinite loops)
|
|
2493
|
+
let stuckAttempts = 0;
|
|
2494
|
+
let lastBufferSample = null;
|
|
2495
|
+
|
|
2496
|
+
// CRITICAL FIX: Remove maxAttempts limit - only stop when buffer is empty or truly stuck
|
|
2497
|
+
while (this.writeBuffer.length > 0) {
|
|
2498
|
+
const currentLength = this.writeBuffer.length;
|
|
2499
|
+
const currentSample = this._getBufferSample(); // Get lightweight sample
|
|
2500
|
+
|
|
2501
|
+
// Process write buffer items
|
|
2502
|
+
await this._processWriteBuffer();
|
|
2503
|
+
|
|
2504
|
+
// Check if buffer is actually stuck (same data) vs new data being added
|
|
2505
|
+
if (this.writeBuffer.length === currentLength) {
|
|
2506
|
+
// Check if the data is identical (stuck) or new data was added
|
|
2507
|
+
if (this._isBufferSampleIdentical(currentSample, lastBufferSample)) {
|
|
2508
|
+
stuckAttempts++;
|
|
2509
|
+
if (this.opts.debugMode) {
|
|
2510
|
+
console.log(`💾 Flush: Buffer appears stuck (identical data), attempt ${stuckAttempts}/${maxStuckAttempts}`);
|
|
2511
|
+
}
|
|
2512
|
+
if (stuckAttempts >= maxStuckAttempts) {
|
|
2513
|
+
throw new Error(`Write buffer flush stuck - identical data detected after ${maxStuckAttempts} attempts`);
|
|
2514
|
+
}
|
|
2515
|
+
} else {
|
|
2516
|
+
// New data was added, reset stuck counter
|
|
2517
|
+
stuckAttempts = 0;
|
|
2518
|
+
if (this.opts.debugMode) {
|
|
2519
|
+
console.log(`💾 Flush: New data detected, continuing flush (${this.writeBuffer.length} items remaining)`);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
lastBufferSample = currentSample;
|
|
2523
|
+
} else {
|
|
2524
|
+
// Progress was made, reset stuck counter
|
|
2525
|
+
stuckAttempts = 0;
|
|
2526
|
+
lastBufferSample = null;
|
|
2527
|
+
if (this.opts.debugMode) {
|
|
2528
|
+
console.log(`💾 Flush: Progress made, ${currentLength - this.writeBuffer.length} items processed, ${this.writeBuffer.length} remaining`);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
attempts++;
|
|
2532
|
+
|
|
2533
|
+
// Small delay to allow ongoing operations to complete
|
|
2534
|
+
if (this.writeBuffer.length > 0) {
|
|
2535
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// CRITICAL FIX: Remove the artificial limit check - buffer should be empty by now
|
|
2540
|
+
// If we reach here, the buffer is guaranteed to be empty due to the while condition
|
|
2541
|
+
|
|
2542
|
+
if (this.opts.debugMode) {
|
|
2543
|
+
console.log(`💾 Flush completed successfully after ${attempts} attempts`);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* Get a lightweight sample of the write buffer for comparison
|
|
2549
|
+
* @returns {Object} - Sample data for comparison
|
|
2550
|
+
*/
|
|
2551
|
+
_getBufferSample() {
|
|
2552
|
+
if (!this.writeBuffer || this.writeBuffer.length === 0) {
|
|
2553
|
+
return null;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Create a lightweight sample using first few items and their IDs
|
|
2557
|
+
const sampleSize = Math.min(5, this.writeBuffer.length);
|
|
2558
|
+
const sample = {
|
|
2559
|
+
length: this.writeBuffer.length,
|
|
2560
|
+
firstIds: [],
|
|
2561
|
+
lastIds: [],
|
|
2562
|
+
checksum: 0
|
|
2563
|
+
};
|
|
2564
|
+
|
|
2565
|
+
// Sample first few items
|
|
2566
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
2567
|
+
const item = this.writeBuffer[i];
|
|
2568
|
+
if (item && item.id) {
|
|
2569
|
+
sample.firstIds.push(item.id);
|
|
2570
|
+
// Simple checksum using ID hash
|
|
2571
|
+
sample.checksum += item.id.toString().split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// Sample last few items if buffer is large
|
|
2576
|
+
if (this.writeBuffer.length > sampleSize) {
|
|
2577
|
+
for (let i = Math.max(0, this.writeBuffer.length - sampleSize); i < this.writeBuffer.length; i++) {
|
|
2578
|
+
const item = this.writeBuffer[i];
|
|
2579
|
+
if (item && item.id) {
|
|
2580
|
+
sample.lastIds.push(item.id);
|
|
2581
|
+
sample.checksum += item.id.toString().split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
return sample;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* Check if two buffer samples are identical (indicating stuck flush)
|
|
2590
|
+
* @param {Object} sample1 - First sample
|
|
2591
|
+
* @param {Object} sample2 - Second sample
|
|
2592
|
+
* @returns {boolean} - True if samples are identical
|
|
2593
|
+
*/
|
|
2594
|
+
_isBufferSampleIdentical(sample1, sample2) {
|
|
2595
|
+
if (!sample1 || !sample2) {
|
|
2596
|
+
return false;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// Quick checks: different lengths or checksums mean different data
|
|
2600
|
+
if (sample1.length !== sample2.length || sample1.checksum !== sample2.checksum) {
|
|
2601
|
+
return false;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
// Compare first IDs
|
|
2605
|
+
if (sample1.firstIds.length !== sample2.firstIds.length) {
|
|
2606
|
+
return false;
|
|
2607
|
+
}
|
|
2608
|
+
for (let i = 0; i < sample1.firstIds.length; i++) {
|
|
2609
|
+
if (sample1.firstIds[i] !== sample2.firstIds[i]) {
|
|
2610
|
+
return false;
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
// Compare last IDs
|
|
2615
|
+
if (sample1.lastIds.length !== sample2.lastIds.length) {
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
for (let i = 0; i < sample1.lastIds.length; i++) {
|
|
2619
|
+
if (sample1.lastIds[i] !== sample2.lastIds[i]) {
|
|
2620
|
+
return false;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
return true;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
/**
|
|
2627
|
+
* Process write buffer items
|
|
2628
|
+
*/
|
|
2629
|
+
async _processWriteBuffer() {
|
|
2630
|
+
// Process write buffer items without loading entire file
|
|
2631
|
+
// OPTIMIZATION: Use Set directly for both processing and lookup - single variable, better performance
|
|
2632
|
+
const itemsToProcess = new Set(this.writeBuffer);
|
|
2633
|
+
|
|
2634
|
+
// CRITICAL FIX: Don't clear writeBuffer immediately - wait for processing to complete
|
|
2635
|
+
// This prevents race conditions where new operations arrive while old ones are still processing
|
|
2636
|
+
|
|
2637
|
+
// OPTIMIZATION: Separate buffer items from object items for batch processing
|
|
2638
|
+
const bufferItems = [];
|
|
2639
|
+
const objectItems = [];
|
|
2640
|
+
for (const item of itemsToProcess) {
|
|
2641
|
+
if (Buffer.isBuffer(item)) {
|
|
2642
|
+
bufferItems.push(item);
|
|
2643
|
+
} else if (typeof item === 'object' && item !== null) {
|
|
2644
|
+
objectItems.push(item);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Process buffer items individually (they're already optimized)
|
|
2649
|
+
for (const buffer of bufferItems) {
|
|
2650
|
+
await this._processBufferItem(buffer);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// OPTIMIZATION: Process all object items in a single write operation
|
|
2654
|
+
if (objectItems.length > 0) {
|
|
2655
|
+
await this._processObjectItemsBatch(objectItems);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// CRITICAL FIX: Only remove processed items from writeBuffer after all async operations complete
|
|
2659
|
+
// OPTIMIZATION: Use Set.has() for O(1) lookup - same Set used for processing
|
|
2660
|
+
const beforeLength = this.writeBuffer.length;
|
|
2661
|
+
this.writeBuffer = this.writeBuffer.filter(item => !itemsToProcess.has(item));
|
|
2662
|
+
const afterLength = this.writeBuffer.length;
|
|
2663
|
+
if (this.opts.debugMode && beforeLength !== afterLength) {
|
|
2664
|
+
console.log(`💾 _processWriteBuffer: Removed ${beforeLength - afterLength} items from writeBuffer (${beforeLength} -> ${afterLength})`);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/**
|
|
2669
|
+
* Process individual buffer item
|
|
2670
|
+
*/
|
|
2671
|
+
async _processBufferItem(buffer) {
|
|
2672
|
+
// Process buffer item without loading entire file
|
|
2673
|
+
// This ensures we don't load the entire data file into memory
|
|
2674
|
+
if (this.fileHandler) {
|
|
2675
|
+
// Use writeDataAsync for non-blocking I/O
|
|
2676
|
+
await this.fileHandler.writeDataAsync(buffer);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
/**
|
|
2681
|
+
* Process individual object item
|
|
2682
|
+
*/
|
|
2683
|
+
async _processObjectItem(obj) {
|
|
2684
|
+
// Process object item without loading entire file
|
|
2685
|
+
if (this.fileHandler) {
|
|
2686
|
+
// SPACE OPTIMIZATION: Remove term IDs before serialization
|
|
2687
|
+
const cleanRecord = this.removeTermIdsForSerialization(obj);
|
|
2688
|
+
const jsonString = this.serializer.serialize(cleanRecord).toString('utf8');
|
|
2689
|
+
// Use writeDataAsync for non-blocking I/O
|
|
2690
|
+
await this.fileHandler.writeDataAsync(Buffer.from(jsonString, 'utf8'));
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/**
|
|
2695
|
+
* Process multiple object items in a single batch write operation
|
|
2696
|
+
*/
|
|
2697
|
+
async _processObjectItemsBatch(objects) {
|
|
2698
|
+
if (!this.fileHandler || objects.length === 0) return;
|
|
2699
|
+
|
|
2700
|
+
// OPTIMIZATION: Combine all objects into a single buffer for one write operation
|
|
2701
|
+
// SPACE OPTIMIZATION: Remove term IDs before serialization
|
|
2702
|
+
const jsonStrings = objects.map(obj => this.serializer.serialize(this.removeTermIdsForSerialization(obj)).toString('utf8'));
|
|
2703
|
+
const combinedString = jsonStrings.join('');
|
|
2704
|
+
|
|
2705
|
+
// CRITICAL FIX: Validate that the combined string ends with newline
|
|
2706
|
+
const validatedString = combinedString.endsWith('\n') ? combinedString : combinedString + '\n';
|
|
2707
|
+
const buffer = Buffer.from(validatedString, 'utf8');
|
|
2708
|
+
|
|
2709
|
+
// Single write operation for all objects
|
|
2710
|
+
await this.fileHandler.writeDataAsync(buffer);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/**
|
|
2714
|
+
* Wait for all I/O operations to complete
|
|
2715
|
+
*/
|
|
2716
|
+
async _waitForIOCompletion() {
|
|
2717
|
+
// Wait for all file operations to complete
|
|
2718
|
+
if (this.fileHandler && this.fileHandler.fileMutex) {
|
|
2719
|
+
await this.fileHandler.fileMutex.runExclusive(async () => {
|
|
2720
|
+
// Ensure all pending file operations complete
|
|
2721
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/**
|
|
2727
|
+
* CRITICAL FIX: Safe fallback method to load existing records when _streamExistingRecords fails
|
|
2728
|
+
* This prevents data loss by attempting alternative methods to preserve existing data
|
|
2729
|
+
*/
|
|
2730
|
+
async _loadExistingRecordsFallback(deletedIdsSnapshot, writeBufferSnapshot) {
|
|
2731
|
+
const existingRecords = [];
|
|
2732
|
+
try {
|
|
2733
|
+
if (this.opts.debugMode) {
|
|
2734
|
+
console.log(`💾 Save: Attempting fallback method to load existing records`);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// Method 1: Try to read the entire file and filter
|
|
2738
|
+
if (this.fileHandler.exists()) {
|
|
2739
|
+
const fs = await Promise.resolve().then(() => _interopRequireWildcard(require('fs')));
|
|
2740
|
+
const fileContent = await fs.promises.readFile(this.normalizedFile, 'utf8');
|
|
2741
|
+
const lines = fileContent.split('\n').filter(line => line.trim());
|
|
2742
|
+
for (let i = 0; i < lines.length && i < this.offsets.length; i++) {
|
|
2743
|
+
try {
|
|
2744
|
+
const record = this.serializer.deserialize(lines[i]);
|
|
2745
|
+
if (record && !deletedIdsSnapshot.has(record.id)) {
|
|
2746
|
+
// Check if this record is not being updated in writeBuffer
|
|
2747
|
+
const updatedRecord = writeBufferSnapshot.find(r => r.id === record.id);
|
|
2748
|
+
if (!updatedRecord) {
|
|
2749
|
+
existingRecords.push(record);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
} catch (error) {
|
|
2753
|
+
// Skip invalid lines
|
|
2754
|
+
if (this.opts.debugMode) {
|
|
2755
|
+
console.log(`💾 Save: Skipping invalid line ${i} in fallback:`, error.message);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
if (this.opts.debugMode) {
|
|
2761
|
+
console.log(`💾 Save: Fallback method loaded ${existingRecords.length} existing records`);
|
|
2762
|
+
}
|
|
2763
|
+
return existingRecords;
|
|
2764
|
+
} catch (error) {
|
|
2765
|
+
if (this.opts.debugMode) {
|
|
2766
|
+
console.log(`💾 Save: Fallback method failed:`, error.message);
|
|
2767
|
+
}
|
|
2768
|
+
// Return empty array as last resort - better than losing all data
|
|
2769
|
+
return [];
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Stream existing records without loading entire file into memory
|
|
2775
|
+
* Optimized with group ranging and reduced JSON parsing
|
|
2776
|
+
*/
|
|
2777
|
+
async _streamExistingRecords(deletedIdsSnapshot, writeBufferSnapshot) {
|
|
2778
|
+
const existingRecords = [];
|
|
2779
|
+
if (this.offsets.length === 0) {
|
|
2780
|
+
return existingRecords;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
// OPTIMIZATION: Pre-allocate array with known size (but don't set length to avoid undefined slots)
|
|
2784
|
+
// existingRecords.length = this.offsets.length
|
|
2785
|
+
|
|
2786
|
+
// Create a map of updated records for quick lookup
|
|
2787
|
+
const updatedRecordsMap = new Map();
|
|
2788
|
+
writeBufferSnapshot.forEach(record => {
|
|
2789
|
+
updatedRecordsMap.set(record.id, record);
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
// OPTIMIZATION: Cache file stats to avoid repeated stat() calls
|
|
2793
|
+
let fileSize = 0;
|
|
2794
|
+
if (this._cachedFileStats && this._cachedFileStats.timestamp > Date.now() - 1000) {
|
|
2795
|
+
// Use cached stats if less than 1 second old
|
|
2796
|
+
fileSize = this._cachedFileStats.size;
|
|
2797
|
+
} else {
|
|
2798
|
+
// Get fresh stats and cache them
|
|
2799
|
+
const fileStats = (await this.fileHandler.exists()) ? await _fs.default.promises.stat(this.normalizedFile) : null;
|
|
2800
|
+
fileSize = fileStats ? fileStats.size : 0;
|
|
2801
|
+
this._cachedFileStats = {
|
|
2802
|
+
size: fileSize,
|
|
2803
|
+
timestamp: Date.now()
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// CRITICAL FIX: Ensure indexOffset is consistent with actual file size
|
|
2808
|
+
if (this.indexOffset > fileSize) {
|
|
2809
|
+
if (this.opts.debugMode) {
|
|
2810
|
+
console.log(`💾 Save: Correcting indexOffset from ${this.indexOffset} to ${fileSize} (file size)`);
|
|
2811
|
+
}
|
|
2812
|
+
this.indexOffset = fileSize;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
// Build ranges array for group reading
|
|
2816
|
+
const ranges = [];
|
|
2817
|
+
for (let i = 0; i < this.offsets.length; i++) {
|
|
2818
|
+
const offset = this.offsets[i];
|
|
2819
|
+
let nextOffset = i + 1 < this.offsets.length ? this.offsets[i + 1] : this.indexOffset;
|
|
2820
|
+
if (this.opts.debugMode) {
|
|
2821
|
+
console.log(`💾 Save: Building range for record ${i}: offset=${offset}, nextOffset=${nextOffset}`);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// CRITICAL FIX: Handle case where indexOffset is 0 (new database without index)
|
|
2825
|
+
if (nextOffset === 0 && i + 1 >= this.offsets.length) {
|
|
2826
|
+
// For the last record when there's no index yet, we need to find the actual end
|
|
2827
|
+
// Read a bit more data to find the newline character that ends the record
|
|
2828
|
+
const searchEnd = Math.min(offset + 1000, fileSize); // Search up to 1000 bytes ahead
|
|
2829
|
+
if (searchEnd > offset) {
|
|
2830
|
+
try {
|
|
2831
|
+
const searchBuffer = await this.fileHandler.readRange(offset, searchEnd);
|
|
2832
|
+
const searchText = searchBuffer.toString('utf8');
|
|
2833
|
+
|
|
2834
|
+
// Look for the end of the JSON record (closing brace followed by newline or end of data)
|
|
2835
|
+
let recordEnd = -1;
|
|
2836
|
+
let braceCount = 0;
|
|
2837
|
+
let inString = false;
|
|
2838
|
+
let escapeNext = false;
|
|
2839
|
+
for (let j = 0; j < searchText.length; j++) {
|
|
2840
|
+
const char = searchText[j];
|
|
2841
|
+
if (escapeNext) {
|
|
2842
|
+
escapeNext = false;
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
if (char === '\\') {
|
|
2846
|
+
escapeNext = true;
|
|
2847
|
+
continue;
|
|
2848
|
+
}
|
|
2849
|
+
if (char === '"' && !escapeNext) {
|
|
2850
|
+
inString = !inString;
|
|
2851
|
+
continue;
|
|
2852
|
+
}
|
|
2853
|
+
if (!inString) {
|
|
2854
|
+
if (char === '{') {
|
|
2855
|
+
braceCount++;
|
|
2856
|
+
} else if (char === '}') {
|
|
2857
|
+
braceCount--;
|
|
2858
|
+
if (braceCount === 0) {
|
|
2859
|
+
// Found the end of the JSON object
|
|
2860
|
+
recordEnd = j + 1;
|
|
2861
|
+
break;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
if (recordEnd !== -1) {
|
|
2867
|
+
nextOffset = offset + recordEnd;
|
|
2868
|
+
} else {
|
|
2869
|
+
// If we can't find the end, read to end of file
|
|
2870
|
+
nextOffset = fileSize;
|
|
2871
|
+
}
|
|
2872
|
+
} catch (error) {
|
|
2873
|
+
// Fallback to end of file if search fails
|
|
2874
|
+
nextOffset = fileSize;
|
|
2875
|
+
}
|
|
2876
|
+
} else {
|
|
2877
|
+
nextOffset = fileSize;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// Validate offset ranges
|
|
2882
|
+
if (offset < 0) {
|
|
2883
|
+
if (this.opts.debugMode) {
|
|
2884
|
+
console.log(`💾 Save: Skipped negative offset ${offset}`);
|
|
2885
|
+
}
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// CRITICAL FIX: Allow offsets that are at or beyond file size (for new records)
|
|
2890
|
+
if (fileSize > 0 && offset > fileSize) {
|
|
2891
|
+
if (this.opts.debugMode) {
|
|
2892
|
+
console.log(`💾 Save: Skipped offset ${offset} beyond file size ${fileSize}`);
|
|
2893
|
+
}
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2896
|
+
if (nextOffset <= offset) {
|
|
2897
|
+
if (this.opts.debugMode) {
|
|
2898
|
+
console.log(`💾 Save: Skipped invalid range [${offset}, ${nextOffset}]`);
|
|
2899
|
+
}
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
ranges.push({
|
|
2903
|
+
start: offset,
|
|
2904
|
+
end: nextOffset,
|
|
2905
|
+
index: i
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
if (ranges.length === 0) {
|
|
2909
|
+
return existingRecords;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// Use group ranging for efficient reading
|
|
2913
|
+
const recordLines = await this.fileHandler.readRanges(ranges, async (lineString, range) => {
|
|
2914
|
+
if (!lineString || !lineString.trim()) {
|
|
2915
|
+
return null;
|
|
2916
|
+
}
|
|
2917
|
+
const trimmedLine = lineString.trim();
|
|
2918
|
+
|
|
2919
|
+
// DEBUG: Log what we're reading (temporarily enabled for debugging)
|
|
2920
|
+
if (this.opts.debugMode) {
|
|
2921
|
+
console.log(`💾 Save: Reading range ${range.start}-${range.end}, length: ${trimmedLine.length}`);
|
|
2922
|
+
console.log(`💾 Save: First 100 chars: ${trimmedLine.substring(0, 100)}`);
|
|
2923
|
+
if (trimmedLine.length > 100) {
|
|
2924
|
+
console.log(`💾 Save: Last 100 chars: ${trimmedLine.substring(trimmedLine.length - 100)}`);
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// OPTIMIZATION: Try to extract ID without full JSON parsing
|
|
2929
|
+
let recordId = null;
|
|
2930
|
+
let needsFullParse = false;
|
|
2931
|
+
|
|
2932
|
+
// For array format, try to extract ID from array position
|
|
2933
|
+
if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) {
|
|
2934
|
+
// Array format: try to extract ID from the array
|
|
2935
|
+
try {
|
|
2936
|
+
const arrayData = JSON.parse(trimmedLine);
|
|
2937
|
+
if (Array.isArray(arrayData) && arrayData.length > 0) {
|
|
2938
|
+
// For arrays without explicit ID, use the first element as a fallback
|
|
2939
|
+
// or try to find the ID field if it exists
|
|
2940
|
+
if (arrayData.length > 2) {
|
|
2941
|
+
// ID is typically at position 2 in array format [age, city, id, name]
|
|
2942
|
+
recordId = arrayData[2];
|
|
2943
|
+
} else {
|
|
2944
|
+
// For arrays without ID field, use first element as fallback
|
|
2945
|
+
recordId = arrayData[0];
|
|
2946
|
+
}
|
|
2947
|
+
if (recordId !== undefined && recordId !== null) {
|
|
2948
|
+
recordId = String(recordId);
|
|
2949
|
+
// Check if this record needs full parsing (updated or deleted)
|
|
2950
|
+
needsFullParse = updatedRecordsMap.has(recordId) || deletedIdsSnapshot.has(recordId);
|
|
2951
|
+
} else {
|
|
2952
|
+
needsFullParse = true;
|
|
2953
|
+
}
|
|
2954
|
+
} else {
|
|
2955
|
+
needsFullParse = true;
|
|
2956
|
+
}
|
|
2957
|
+
} catch (e) {
|
|
2958
|
+
needsFullParse = true;
|
|
2959
|
+
}
|
|
2960
|
+
} else {
|
|
2961
|
+
// Object format: use regex for backward compatibility
|
|
2962
|
+
const idMatch = trimmedLine.match(/"id"\s*:\s*"([^"]+)"|"id"\s*:\s*(\d+)/);
|
|
2963
|
+
if (idMatch) {
|
|
2964
|
+
recordId = idMatch[1] || idMatch[2];
|
|
2965
|
+
needsFullParse = updatedRecordsMap.has(recordId) || deletedIdsSnapshot.has(recordId);
|
|
2966
|
+
} else {
|
|
2967
|
+
needsFullParse = true;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
if (!needsFullParse) {
|
|
2971
|
+
// Record is unchanged - we can avoid parsing entirely
|
|
2972
|
+
// Store the raw line and parse only when needed for the final result
|
|
2973
|
+
return {
|
|
2974
|
+
type: 'unchanged',
|
|
2975
|
+
line: trimmedLine,
|
|
2976
|
+
id: recordId,
|
|
2977
|
+
needsParse: false
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// Full parsing needed for updated/deleted records
|
|
2982
|
+
try {
|
|
2983
|
+
// Use serializer to properly deserialize array format
|
|
2984
|
+
const record = this.serializer ? this.serializer.deserialize(trimmedLine) : JSON.parse(trimmedLine);
|
|
2985
|
+
|
|
2986
|
+
// Use record directly (no need to restore term IDs)
|
|
2987
|
+
const recordWithIds = record;
|
|
2988
|
+
if (updatedRecordsMap.has(recordWithIds.id)) {
|
|
2989
|
+
// Replace with updated version
|
|
2990
|
+
const updatedRecord = updatedRecordsMap.get(recordWithIds.id);
|
|
2991
|
+
if (this.opts.debugMode) {
|
|
2992
|
+
console.log(`💾 Save: Updated record ${recordWithIds.id} (${recordWithIds.name || 'Unnamed'})`);
|
|
2993
|
+
}
|
|
2994
|
+
return {
|
|
2995
|
+
type: 'updated',
|
|
2996
|
+
record: updatedRecord,
|
|
2997
|
+
id: recordWithIds.id,
|
|
2998
|
+
needsParse: false
|
|
2999
|
+
};
|
|
3000
|
+
} else if (!deletedIdsSnapshot.has(recordWithIds.id)) {
|
|
3001
|
+
// Keep existing record if not deleted
|
|
3002
|
+
if (this.opts.debugMode) {
|
|
3003
|
+
console.log(`💾 Save: Kept record ${recordWithIds.id} (${recordWithIds.name || 'Unnamed'})`);
|
|
3004
|
+
}
|
|
3005
|
+
return {
|
|
3006
|
+
type: 'kept',
|
|
3007
|
+
record: recordWithIds,
|
|
3008
|
+
id: recordWithIds.id,
|
|
3009
|
+
needsParse: false
|
|
3010
|
+
};
|
|
3011
|
+
} else {
|
|
3012
|
+
// Skip deleted record
|
|
3013
|
+
if (this.opts.debugMode) {
|
|
3014
|
+
console.log(`💾 Save: Skipped record ${recordWithIds.id} (${recordWithIds.name || 'Unnamed'}) - deleted`);
|
|
3015
|
+
}
|
|
3016
|
+
return {
|
|
3017
|
+
type: 'deleted',
|
|
3018
|
+
id: recordWithIds.id,
|
|
3019
|
+
needsParse: false
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
} catch (jsonError) {
|
|
3023
|
+
// RACE CONDITION FIX: Skip records that can't be parsed due to incomplete writes
|
|
3024
|
+
if (this.opts.debugMode) {
|
|
3025
|
+
console.log(`💾 Save: Skipped corrupted record at range ${range.start}-${range.end} - ${jsonError.message}`);
|
|
3026
|
+
// console.log(`💾 Save: Problematic line: ${trimmedLine}`)
|
|
3027
|
+
}
|
|
3028
|
+
return null;
|
|
3029
|
+
}
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
// Process results and build final records array
|
|
3033
|
+
// OPTIMIZATION: Pre-allocate arrays with known size
|
|
3034
|
+
const unchangedLines = [];
|
|
3035
|
+
const parsedRecords = [];
|
|
3036
|
+
|
|
3037
|
+
// OPTIMIZATION: Use for loop instead of Object.entries().sort() for better performance
|
|
3038
|
+
const sortedEntries = [];
|
|
3039
|
+
for (const key in recordLines) {
|
|
3040
|
+
if (recordLines.hasOwnProperty(key)) {
|
|
3041
|
+
sortedEntries.push([key, recordLines[key]]);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// OPTIMIZATION: Sort by offset position using numeric comparison
|
|
3046
|
+
sortedEntries.sort(([keyA], [keyB]) => parseInt(keyA) - parseInt(keyB));
|
|
3047
|
+
|
|
3048
|
+
// CRITICAL FIX: Maintain record order by processing in original offset order
|
|
3049
|
+
// and tracking which records are being kept vs deleted
|
|
3050
|
+
const keptRecords = [];
|
|
3051
|
+
const deletedOffsets = new Set();
|
|
3052
|
+
for (const [rangeKey, result] of sortedEntries) {
|
|
3053
|
+
if (!result) continue;
|
|
3054
|
+
const offset = parseInt(rangeKey);
|
|
3055
|
+
switch (result.type) {
|
|
3056
|
+
case 'unchanged':
|
|
3057
|
+
// Collect unchanged lines for batch processing
|
|
3058
|
+
unchangedLines.push(result.line);
|
|
3059
|
+
keptRecords.push({
|
|
3060
|
+
offset,
|
|
3061
|
+
type: 'unchanged',
|
|
3062
|
+
line: result.line
|
|
3063
|
+
});
|
|
3064
|
+
break;
|
|
3065
|
+
case 'updated':
|
|
3066
|
+
case 'kept':
|
|
3067
|
+
parsedRecords.push(result.record);
|
|
3068
|
+
keptRecords.push({
|
|
3069
|
+
offset,
|
|
3070
|
+
type: 'parsed',
|
|
3071
|
+
record: result.record
|
|
3072
|
+
});
|
|
3073
|
+
break;
|
|
3074
|
+
case 'deleted':
|
|
3075
|
+
// Track deleted records by their offset
|
|
3076
|
+
deletedOffsets.add(offset);
|
|
3077
|
+
break;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// CRITICAL FIX: Build final records array in the correct order
|
|
3082
|
+
// and update offsets array to match the new record order
|
|
3083
|
+
const newOffsets = [];
|
|
3084
|
+
let currentOffset = 0;
|
|
3085
|
+
|
|
3086
|
+
// OPTIMIZATION: Batch parse unchanged records for better performance
|
|
3087
|
+
if (unchangedLines.length > 0) {
|
|
3088
|
+
const batchParsedRecords = [];
|
|
3089
|
+
for (let i = 0; i < unchangedLines.length; i++) {
|
|
3090
|
+
try {
|
|
3091
|
+
// Use serializer to properly deserialize array format
|
|
3092
|
+
const record = this.serializer ? this.serializer.deserialize(unchangedLines[i]) : JSON.parse(unchangedLines[i]);
|
|
3093
|
+
batchParsedRecords.push(record);
|
|
3094
|
+
} catch (jsonError) {
|
|
3095
|
+
if (this.opts.debugMode) {
|
|
3096
|
+
console.log(`💾 Save: Failed to parse unchanged record: ${jsonError.message}`);
|
|
3097
|
+
}
|
|
3098
|
+
batchParsedRecords.push(null); // Mark as failed
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// Process kept records in their original offset order
|
|
3103
|
+
let batchIndex = 0;
|
|
3104
|
+
for (const keptRecord of keptRecords) {
|
|
3105
|
+
let record = null;
|
|
3106
|
+
if (keptRecord.type === 'unchanged') {
|
|
3107
|
+
record = batchParsedRecords[batchIndex++];
|
|
3108
|
+
if (!record) continue; // Skip failed parses
|
|
3109
|
+
} else if (keptRecord.type === 'parsed') {
|
|
3110
|
+
record = keptRecord.record;
|
|
3111
|
+
}
|
|
3112
|
+
if (record && typeof record === 'object') {
|
|
3113
|
+
existingRecords.push(record);
|
|
3114
|
+
newOffsets.push(currentOffset);
|
|
3115
|
+
// OPTIMIZATION: Use cached string length if available
|
|
3116
|
+
const recordSize = keptRecord.type === 'unchanged' ? keptRecord.line.length + 1 // Use actual line length
|
|
3117
|
+
: JSON.stringify(this.removeTermIdsForSerialization(record)).length + 1;
|
|
3118
|
+
currentOffset += recordSize;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
} else {
|
|
3122
|
+
// Process kept records in their original offset order (no unchanged records)
|
|
3123
|
+
for (const keptRecord of keptRecords) {
|
|
3124
|
+
if (keptRecord.type === 'parsed') {
|
|
3125
|
+
const record = keptRecord.record;
|
|
3126
|
+
if (record && typeof record === 'object' && record.id) {
|
|
3127
|
+
existingRecords.push(record);
|
|
3128
|
+
newOffsets.push(currentOffset);
|
|
3129
|
+
const recordSize = JSON.stringify(this.removeTermIdsForSerialization(record)).length + 1;
|
|
3130
|
+
currentOffset += recordSize;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// Update the offsets array to reflect the new record order
|
|
3137
|
+
this.offsets = newOffsets;
|
|
3138
|
+
return existingRecords;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
/**
|
|
3142
|
+
* Flush write buffer
|
|
3143
|
+
*/
|
|
3144
|
+
async flush() {
|
|
3145
|
+
return this.operationQueue.enqueue(async () => {
|
|
3146
|
+
this.isInsideOperationQueue = true;
|
|
3147
|
+
try {
|
|
3148
|
+
// CRITICAL FIX: Actually flush the writeBuffer by saving data
|
|
3149
|
+
if (this.writeBuffer.length > 0 || this.shouldSave) {
|
|
3150
|
+
await this._doSave();
|
|
3151
|
+
}
|
|
3152
|
+
return Promise.resolve();
|
|
3153
|
+
} finally {
|
|
3154
|
+
this.isInsideOperationQueue = false;
|
|
3155
|
+
}
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
/**
|
|
3160
|
+
* Flush insertion buffer (backward compatibility)
|
|
3161
|
+
*/
|
|
3162
|
+
async flushInsertionBuffer() {
|
|
3163
|
+
// Flush insertion buffer implementation - save any pending data
|
|
3164
|
+
// Use the same robust flush logic as flush()
|
|
3165
|
+
return this.flush();
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
/**
|
|
3169
|
+
* Get memory usage
|
|
3170
|
+
*/
|
|
3171
|
+
getMemoryUsage() {
|
|
3172
|
+
return {
|
|
3173
|
+
offsetsCount: this.offsets.length,
|
|
3174
|
+
writeBufferSize: this.writeBuffer ? this.writeBuffer.length : 0,
|
|
3175
|
+
used: this.writeBuffer.length,
|
|
3176
|
+
total: this.offsets.length + this.writeBuffer.length,
|
|
3177
|
+
percentage: 0
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
_hasActualIndexData() {
|
|
3181
|
+
if (!this.indexManager) return false;
|
|
3182
|
+
const data = this.indexManager.index.data;
|
|
3183
|
+
for (const field in data) {
|
|
3184
|
+
const fieldData = data[field];
|
|
3185
|
+
for (const value in fieldData) {
|
|
3186
|
+
const hybridData = fieldData[value];
|
|
3187
|
+
if (hybridData.set && hybridData.set.size > 0) {
|
|
3188
|
+
return true;
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
return false;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* Locate a record by line number and return its byte range
|
|
3197
|
+
* @param {number} n - Line number
|
|
3198
|
+
* @returns {Array} - [start, end] byte range or undefined
|
|
3199
|
+
*/
|
|
3200
|
+
locate(n) {
|
|
3201
|
+
if (this.offsets[n] === undefined) {
|
|
3202
|
+
return undefined; // Record doesn't exist
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// CRITICAL FIX: Calculate end offset correctly to prevent cross-line reading
|
|
3206
|
+
let end;
|
|
3207
|
+
if (n + 1 < this.offsets.length) {
|
|
3208
|
+
// Use next record's start minus 1 (to exclude newline) as this record's end
|
|
3209
|
+
end = this.offsets[n + 1] - 1;
|
|
3210
|
+
} else {
|
|
3211
|
+
// For the last record, use indexOffset (includes the record but not newline)
|
|
3212
|
+
end = this.indexOffset;
|
|
3213
|
+
}
|
|
3214
|
+
return [this.offsets[n], end];
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
/**
|
|
3218
|
+
* Get ranges for streaming based on line numbers
|
|
3219
|
+
* @param {Array|Set} map - Line numbers to get ranges for
|
|
3220
|
+
* @returns {Array} - Array of range objects {start, end, index}
|
|
3221
|
+
*/
|
|
3222
|
+
getRanges(map) {
|
|
3223
|
+
return (map || Array.from(this.offsets.keys())).map(n => {
|
|
3224
|
+
const ret = this.locate(n);
|
|
3225
|
+
if (ret !== undefined) return {
|
|
3226
|
+
start: ret[0],
|
|
3227
|
+
end: ret[1],
|
|
3228
|
+
index: n
|
|
3229
|
+
};
|
|
3230
|
+
}).filter(n => n !== undefined);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
/**
|
|
3234
|
+
* Walk through records using streaming (real implementation)
|
|
3235
|
+
*/
|
|
3236
|
+
walk(_x) {
|
|
3237
|
+
var _this = this;
|
|
3238
|
+
return _wrapAsyncGenerator(function* (criteria, options = {}) {
|
|
3239
|
+
// CRITICAL FIX: Validate state before walk operation to prevent crashes
|
|
3240
|
+
_this.validateState();
|
|
3241
|
+
if (!_this.initialized) yield _awaitAsyncGenerator(_this.init());
|
|
3242
|
+
|
|
3243
|
+
// If no data at all, return empty
|
|
3244
|
+
if (_this.indexOffset === 0 && _this.writeBuffer.length === 0) return;
|
|
3245
|
+
let map;
|
|
3246
|
+
if (!Array.isArray(criteria)) {
|
|
3247
|
+
if (criteria instanceof Set) {
|
|
3248
|
+
map = [...criteria];
|
|
3249
|
+
} else if (criteria && typeof criteria === 'object' && Object.keys(criteria).length > 0) {
|
|
3250
|
+
// Only use indexManager.query if criteria has actual filters
|
|
3251
|
+
map = [..._this.indexManager.query(criteria, options)];
|
|
3252
|
+
} else {
|
|
3253
|
+
// For empty criteria {} or null/undefined, get all records
|
|
3254
|
+
// Use writeBuffer length when indexOffset is 0 (data not saved yet)
|
|
3255
|
+
const totalRecords = _this.indexOffset > 0 ? _this.indexOffset : _this.writeBuffer.length;
|
|
3256
|
+
map = [...Array(totalRecords).keys()];
|
|
3257
|
+
}
|
|
3258
|
+
} else {
|
|
3259
|
+
map = criteria;
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// Use writeBuffer when available (unsaved data)
|
|
3263
|
+
if (_this.writeBuffer.length > 0) {
|
|
3264
|
+
let count = 0;
|
|
3265
|
+
|
|
3266
|
+
// If map is empty (no index results) but we have criteria, filter writeBuffer directly
|
|
3267
|
+
if (map.length === 0 && criteria && typeof criteria === 'object' && Object.keys(criteria).length > 0) {
|
|
3268
|
+
for (let i = 0; i < _this.writeBuffer.length; i++) {
|
|
3269
|
+
if (options.limit && count >= options.limit) {
|
|
3270
|
+
break;
|
|
3271
|
+
}
|
|
3272
|
+
const entry = _this.writeBuffer[i];
|
|
3273
|
+
if (entry && _this.queryManager.matchesCriteria(entry, criteria, options)) {
|
|
3274
|
+
count++;
|
|
3275
|
+
if (options.includeOffsets) {
|
|
3276
|
+
yield {
|
|
3277
|
+
entry,
|
|
3278
|
+
start: 0,
|
|
3279
|
+
_: i
|
|
3280
|
+
};
|
|
3281
|
+
} else {
|
|
3282
|
+
if (_this.opts.includeLinePosition) {
|
|
3283
|
+
entry._ = i;
|
|
3284
|
+
}
|
|
3285
|
+
yield entry;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
} else {
|
|
3290
|
+
// Use map-based iteration (for all records or indexed results)
|
|
3291
|
+
for (const lineNumber of map) {
|
|
3292
|
+
if (options.limit && count >= options.limit) {
|
|
3293
|
+
break;
|
|
3294
|
+
}
|
|
3295
|
+
if (lineNumber < _this.writeBuffer.length) {
|
|
3296
|
+
const entry = _this.writeBuffer[lineNumber];
|
|
3297
|
+
if (entry) {
|
|
3298
|
+
count++;
|
|
3299
|
+
if (options.includeOffsets) {
|
|
3300
|
+
yield {
|
|
3301
|
+
entry,
|
|
3302
|
+
start: 0,
|
|
3303
|
+
_: lineNumber
|
|
3304
|
+
};
|
|
3305
|
+
} else {
|
|
3306
|
+
if (_this.opts.includeLinePosition) {
|
|
3307
|
+
entry._ = lineNumber;
|
|
3308
|
+
}
|
|
3309
|
+
yield entry;
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// If writeBuffer is empty but we have saved data, we need to load it from file
|
|
3319
|
+
if (_this.writeBuffer.length === 0 && _this.indexOffset > 0) {
|
|
3320
|
+
// Load data from file for querying
|
|
3321
|
+
try {
|
|
3322
|
+
let data;
|
|
3323
|
+
let lines;
|
|
3324
|
+
|
|
3325
|
+
// Smart threshold: decide between partial reads vs full read
|
|
3326
|
+
const resultPercentage = map ? map.length / _this.indexOffset * 100 : 100;
|
|
3327
|
+
const threshold = _this.opts.partialReadThreshold || 60; // Default 60% threshold
|
|
3328
|
+
|
|
3329
|
+
// Use partial reads when:
|
|
3330
|
+
// 1. We have specific line numbers from index
|
|
3331
|
+
// 2. Results are below threshold percentage
|
|
3332
|
+
// 3. Database is large enough to benefit from partial reads
|
|
3333
|
+
const shouldUsePartialReads = map && map.length > 0 && resultPercentage < threshold && _this.indexOffset > 100; // Only for databases with >100 records
|
|
3334
|
+
|
|
3335
|
+
if (shouldUsePartialReads) {
|
|
3336
|
+
if (_this.opts.debugMode) {
|
|
3337
|
+
console.log(`🔍 Using PARTIAL READS: ${map.length}/${_this.indexOffset} records (${resultPercentage.toFixed(1)}% < ${threshold}% threshold)`);
|
|
3338
|
+
}
|
|
3339
|
+
// Convert 0-based line numbers to 1-based for readSpecificLines
|
|
3340
|
+
const lineNumbers = map.map(num => num + 1);
|
|
3341
|
+
data = yield _awaitAsyncGenerator(_this.fileHandler.readSpecificLines(lineNumbers));
|
|
3342
|
+
lines = data ? data.split('\n') : [];
|
|
3343
|
+
} else {
|
|
3344
|
+
if (_this.opts.debugMode) {
|
|
3345
|
+
console.log(`🔍 Using STREAMING READ: ${map?.length || 0}/${_this.indexOffset} records (${resultPercentage.toFixed(1)}% >= ${threshold}% threshold or small DB)`);
|
|
3346
|
+
}
|
|
3347
|
+
// Use streaming instead of loading all data in memory
|
|
3348
|
+
// This prevents memory issues with large databases
|
|
3349
|
+
const streamingResults = yield _awaitAsyncGenerator(_this.fileHandler.readWithStreaming(criteria, {
|
|
3350
|
+
limit: options.limit,
|
|
3351
|
+
skip: options.skip
|
|
3352
|
+
}, matchesCriteria, _this.serializer));
|
|
3353
|
+
|
|
3354
|
+
// Process streaming results directly without loading all lines
|
|
3355
|
+
for (const record of streamingResults) {
|
|
3356
|
+
if (options.limit && count >= options.limit) {
|
|
3357
|
+
break;
|
|
3358
|
+
}
|
|
3359
|
+
count++;
|
|
3360
|
+
|
|
3361
|
+
// SPACE OPTIMIZATION: Restore term IDs to terms for user
|
|
3362
|
+
const recordWithTerms = _this.restoreTermIdsAfterDeserialization(record);
|
|
3363
|
+
if (options.includeOffsets) {
|
|
3364
|
+
yield {
|
|
3365
|
+
entry: recordWithTerms,
|
|
3366
|
+
start: 0,
|
|
3367
|
+
_: 0
|
|
3368
|
+
};
|
|
3369
|
+
} else {
|
|
3370
|
+
if (_this.opts.includeLinePosition) {
|
|
3371
|
+
recordWithTerms._ = 0;
|
|
3372
|
+
}
|
|
3373
|
+
yield recordWithTerms;
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
return; // Exit early since we processed streaming results
|
|
3377
|
+
}
|
|
3378
|
+
if (lines.length > 0) {
|
|
3379
|
+
const records = [];
|
|
3380
|
+
for (const line of lines) {
|
|
3381
|
+
if (line.trim()) {
|
|
3382
|
+
try {
|
|
3383
|
+
// CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
|
|
3384
|
+
const record = _this.serializer.deserialize(line);
|
|
3385
|
+
// SPACE OPTIMIZATION: Restore term IDs to terms for user
|
|
3386
|
+
const recordWithTerms = _this.restoreTermIdsAfterDeserialization(record);
|
|
3387
|
+
records.push(recordWithTerms);
|
|
3388
|
+
} catch (error) {
|
|
3389
|
+
// Skip invalid lines
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// Use loaded records for querying
|
|
3395
|
+
let count = 0;
|
|
3396
|
+
|
|
3397
|
+
// When using partial reads, records correspond to the requested line numbers
|
|
3398
|
+
if (shouldUsePartialReads) {
|
|
3399
|
+
for (let i = 0; i < Math.min(records.length, map.length); i++) {
|
|
3400
|
+
if (options.limit && count >= options.limit) {
|
|
3401
|
+
break;
|
|
3402
|
+
}
|
|
3403
|
+
const entry = records[i];
|
|
3404
|
+
const lineNumber = map[i];
|
|
3405
|
+
if (entry) {
|
|
3406
|
+
count++;
|
|
3407
|
+
if (options.includeOffsets) {
|
|
3408
|
+
yield {
|
|
3409
|
+
entry,
|
|
3410
|
+
start: 0,
|
|
3411
|
+
_: lineNumber
|
|
3412
|
+
};
|
|
3413
|
+
} else {
|
|
3414
|
+
if (_this.opts.includeLinePosition) {
|
|
3415
|
+
entry._ = lineNumber;
|
|
3416
|
+
}
|
|
3417
|
+
yield entry;
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
} else {
|
|
3422
|
+
// Fallback to original logic when reading all data
|
|
3423
|
+
for (const lineNumber of map) {
|
|
3424
|
+
if (options.limit && count >= options.limit) {
|
|
3425
|
+
break;
|
|
3426
|
+
}
|
|
3427
|
+
if (lineNumber < records.length) {
|
|
3428
|
+
const entry = records[lineNumber];
|
|
3429
|
+
if (entry) {
|
|
3430
|
+
count++;
|
|
3431
|
+
if (options.includeOffsets) {
|
|
3432
|
+
yield {
|
|
3433
|
+
entry,
|
|
3434
|
+
start: 0,
|
|
3435
|
+
_: lineNumber
|
|
3436
|
+
};
|
|
3437
|
+
} else {
|
|
3438
|
+
if (_this.opts.includeLinePosition) {
|
|
3439
|
+
entry._ = lineNumber;
|
|
3440
|
+
}
|
|
3441
|
+
yield entry;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
} catch (error) {
|
|
3450
|
+
// If file reading fails, continue to file-based streaming
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
// Use file-based streaming for saved data
|
|
3455
|
+
const ranges = _this.getRanges(map);
|
|
3456
|
+
const groupedRanges = yield _awaitAsyncGenerator(_this.fileHandler.groupedRanges(ranges));
|
|
3457
|
+
const fd = yield _awaitAsyncGenerator(_fs.default.promises.open(_this.fileHandler.file, 'r'));
|
|
3458
|
+
try {
|
|
3459
|
+
let count = 0;
|
|
3460
|
+
for (const groupedRange of groupedRanges) {
|
|
3461
|
+
if (options.limit && count >= options.limit) {
|
|
3462
|
+
break;
|
|
3463
|
+
}
|
|
3464
|
+
var _iteratorAbruptCompletion = false;
|
|
3465
|
+
var _didIteratorError = false;
|
|
3466
|
+
var _iteratorError;
|
|
3467
|
+
try {
|
|
3468
|
+
for (var _iterator = _asyncIterator(_this.fileHandler.readGroupedRange(groupedRange, fd)), _step; _iteratorAbruptCompletion = !(_step = yield _awaitAsyncGenerator(_iterator.next())).done; _iteratorAbruptCompletion = false) {
|
|
3469
|
+
const row = _step.value;
|
|
3470
|
+
{
|
|
3471
|
+
if (options.limit && count >= options.limit) {
|
|
3472
|
+
break;
|
|
3473
|
+
}
|
|
3474
|
+
const entry = yield _awaitAsyncGenerator(_this.serializer.deserialize(row.line, {
|
|
3475
|
+
compress: _this.opts.compress,
|
|
3476
|
+
v8: _this.opts.v8
|
|
3477
|
+
}));
|
|
3478
|
+
if (entry === null) continue;
|
|
3479
|
+
|
|
3480
|
+
// SPACE OPTIMIZATION: Restore term IDs to terms for user
|
|
3481
|
+
const entryWithTerms = _this.restoreTermIdsAfterDeserialization(entry);
|
|
3482
|
+
count++;
|
|
3483
|
+
if (options.includeOffsets) {
|
|
3484
|
+
yield {
|
|
3485
|
+
entry: entryWithTerms,
|
|
3486
|
+
start: row.start,
|
|
3487
|
+
_: row._ || _this.offsets.findIndex(n => n === row.start)
|
|
3488
|
+
};
|
|
3489
|
+
} else {
|
|
3490
|
+
if (_this.opts.includeLinePosition) {
|
|
3491
|
+
entryWithTerms._ = row._ || _this.offsets.findIndex(n => n === row.start);
|
|
3492
|
+
}
|
|
3493
|
+
yield entryWithTerms;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
} catch (err) {
|
|
3498
|
+
_didIteratorError = true;
|
|
3499
|
+
_iteratorError = err;
|
|
3500
|
+
} finally {
|
|
3501
|
+
try {
|
|
3502
|
+
if (_iteratorAbruptCompletion && _iterator.return != null) {
|
|
3503
|
+
yield _awaitAsyncGenerator(_iterator.return());
|
|
3504
|
+
}
|
|
3505
|
+
} finally {
|
|
3506
|
+
if (_didIteratorError) {
|
|
3507
|
+
throw _iteratorError;
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
} finally {
|
|
3513
|
+
yield _awaitAsyncGenerator(fd.close());
|
|
3514
|
+
}
|
|
3515
|
+
}).apply(this, arguments);
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
/**
|
|
3519
|
+
* Iterate through records with bulk update capabilities
|
|
3520
|
+
* Allows in-place modifications and deletions with optimized performance
|
|
3521
|
+
*
|
|
3522
|
+
* @param {Object} criteria - Query criteria
|
|
3523
|
+
* @param {Object} options - Iteration options
|
|
3524
|
+
* @param {number} options.chunkSize - Batch size for processing (default: 1000)
|
|
3525
|
+
* @param {string} options.strategy - Processing strategy: 'streaming' (always uses walk() method)
|
|
3526
|
+
* @param {boolean} options.autoSave - Auto-save after each chunk (default: false)
|
|
3527
|
+
* @param {Function} options.progressCallback - Progress callback function
|
|
3528
|
+
* @param {boolean} options.detectChanges - Auto-detect changes (default: true)
|
|
3529
|
+
* @returns {AsyncGenerator} Generator yielding records for modification
|
|
3530
|
+
*/
|
|
3531
|
+
iterate(_x2) {
|
|
3532
|
+
var _this2 = this;
|
|
3533
|
+
return _wrapAsyncGenerator(function* (criteria, options = {}) {
|
|
3534
|
+
// CRITICAL FIX: Validate state before iterate operation
|
|
3535
|
+
_this2.validateState();
|
|
3536
|
+
if (!_this2.initialized) yield _awaitAsyncGenerator(_this2.init());
|
|
3537
|
+
|
|
3538
|
+
// Set default options
|
|
3539
|
+
const opts = {
|
|
3540
|
+
chunkSize: 1000,
|
|
3541
|
+
strategy: 'streaming',
|
|
3542
|
+
// Always use walk() method for optimal performance
|
|
3543
|
+
autoSave: false,
|
|
3544
|
+
detectChanges: true,
|
|
3545
|
+
...options
|
|
3546
|
+
};
|
|
3547
|
+
|
|
3548
|
+
// If no data, return empty
|
|
3549
|
+
if (_this2.indexOffset === 0 && _this2.writeBuffer.length === 0) return;
|
|
3550
|
+
const startTime = Date.now();
|
|
3551
|
+
let processedCount = 0;
|
|
3552
|
+
let modifiedCount = 0;
|
|
3553
|
+
let deletedCount = 0;
|
|
3554
|
+
|
|
3555
|
+
// Buffers for batch processing
|
|
3556
|
+
const updateBuffer = [];
|
|
3557
|
+
const deleteBuffer = new Set();
|
|
3558
|
+
const originalRecords = new Map(); // Track original records for change detection
|
|
3559
|
+
|
|
3560
|
+
try {
|
|
3561
|
+
// Always use walk() now that the bug is fixed - it works for both small and large datasets
|
|
3562
|
+
var _iteratorAbruptCompletion2 = false;
|
|
3563
|
+
var _didIteratorError2 = false;
|
|
3564
|
+
var _iteratorError2;
|
|
3565
|
+
try {
|
|
3566
|
+
for (var _iterator2 = _asyncIterator(_this2.walk(criteria, options)), _step2; _iteratorAbruptCompletion2 = !(_step2 = yield _awaitAsyncGenerator(_iterator2.next())).done; _iteratorAbruptCompletion2 = false) {
|
|
3567
|
+
const entry = _step2.value;
|
|
3568
|
+
{
|
|
3569
|
+
processedCount++;
|
|
3570
|
+
|
|
3571
|
+
// Store original record for change detection BEFORE yielding
|
|
3572
|
+
let originalRecord = null;
|
|
3573
|
+
if (opts.detectChanges) {
|
|
3574
|
+
originalRecord = _this2._createShallowCopy(entry);
|
|
3575
|
+
originalRecords.set(entry.id, originalRecord);
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
// Create wrapper based on performance preference
|
|
3579
|
+
const entryWrapper = opts.highPerformance ? _this2._createHighPerformanceWrapper(entry, originalRecord) : _this2._createEntryProxy(entry, originalRecord);
|
|
3580
|
+
|
|
3581
|
+
// Yield the wrapper for user modification
|
|
3582
|
+
yield entryWrapper;
|
|
3583
|
+
|
|
3584
|
+
// Check if entry was modified or deleted AFTER yielding
|
|
3585
|
+
if (entryWrapper.isMarkedForDeletion) {
|
|
3586
|
+
// Entry was marked for deletion
|
|
3587
|
+
if (originalRecord) {
|
|
3588
|
+
deleteBuffer.add(originalRecord.id);
|
|
3589
|
+
deletedCount++;
|
|
3590
|
+
}
|
|
3591
|
+
} else if (opts.detectChanges && originalRecord) {
|
|
3592
|
+
// Check if entry was modified by comparing with original (optimized comparison)
|
|
3593
|
+
if (_this2._hasRecordChanged(entry, originalRecord)) {
|
|
3594
|
+
updateBuffer.push(entry);
|
|
3595
|
+
modifiedCount++;
|
|
3596
|
+
}
|
|
3597
|
+
} else if (entryWrapper.isModified) {
|
|
3598
|
+
// Manual change detection
|
|
3599
|
+
updateBuffer.push(entry);
|
|
3600
|
+
modifiedCount++;
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
// Process batch when chunk size is reached
|
|
3604
|
+
if (updateBuffer.length >= opts.chunkSize || deleteBuffer.size >= opts.chunkSize) {
|
|
3605
|
+
yield _awaitAsyncGenerator(_this2._processIterateBatch(updateBuffer, deleteBuffer, opts));
|
|
3606
|
+
|
|
3607
|
+
// Clear buffers
|
|
3608
|
+
updateBuffer.length = 0;
|
|
3609
|
+
deleteBuffer.clear();
|
|
3610
|
+
originalRecords.clear();
|
|
3611
|
+
|
|
3612
|
+
// Progress callback
|
|
3613
|
+
if (opts.progressCallback) {
|
|
3614
|
+
opts.progressCallback({
|
|
3615
|
+
processed: processedCount,
|
|
3616
|
+
modified: modifiedCount,
|
|
3617
|
+
deleted: deletedCount,
|
|
3618
|
+
elapsed: Date.now() - startTime
|
|
3619
|
+
});
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
// Process remaining records in buffers
|
|
3626
|
+
} catch (err) {
|
|
3627
|
+
_didIteratorError2 = true;
|
|
3628
|
+
_iteratorError2 = err;
|
|
3629
|
+
} finally {
|
|
3630
|
+
try {
|
|
3631
|
+
if (_iteratorAbruptCompletion2 && _iterator2.return != null) {
|
|
3632
|
+
yield _awaitAsyncGenerator(_iterator2.return());
|
|
3633
|
+
}
|
|
3634
|
+
} finally {
|
|
3635
|
+
if (_didIteratorError2) {
|
|
3636
|
+
throw _iteratorError2;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
if (updateBuffer.length > 0 || deleteBuffer.size > 0) {
|
|
3641
|
+
yield _awaitAsyncGenerator(_this2._processIterateBatch(updateBuffer, deleteBuffer, opts));
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
// Final progress callback (always called)
|
|
3645
|
+
if (opts.progressCallback) {
|
|
3646
|
+
opts.progressCallback({
|
|
3647
|
+
processed: processedCount,
|
|
3648
|
+
modified: modifiedCount,
|
|
3649
|
+
deleted: deletedCount,
|
|
3650
|
+
elapsed: Date.now() - startTime,
|
|
3651
|
+
completed: true
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
if (_this2.opts.debugMode) {
|
|
3655
|
+
console.log(`🔄 ITERATE COMPLETED: ${processedCount} processed, ${modifiedCount} modified, ${deletedCount} deleted in ${Date.now() - startTime}ms`);
|
|
3656
|
+
}
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
console.error('Iterate operation failed:', error);
|
|
3659
|
+
throw error;
|
|
3660
|
+
}
|
|
3661
|
+
}).apply(this, arguments);
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
/**
|
|
3665
|
+
* Process a batch of updates and deletes from iterate operation
|
|
3666
|
+
* @private
|
|
3667
|
+
*/
|
|
3668
|
+
async _processIterateBatch(updateBuffer, deleteBuffer, options) {
|
|
3669
|
+
if (updateBuffer.length === 0 && deleteBuffer.size === 0) return;
|
|
3670
|
+
const startTime = Date.now();
|
|
3671
|
+
try {
|
|
3672
|
+
// Process updates
|
|
3673
|
+
if (updateBuffer.length > 0) {
|
|
3674
|
+
for (const record of updateBuffer) {
|
|
3675
|
+
// Remove the _modified flag if it exists
|
|
3676
|
+
delete record._modified;
|
|
3677
|
+
|
|
3678
|
+
// Update record in writeBuffer or add to writeBuffer
|
|
3679
|
+
const index = this.writeBuffer.findIndex(r => r.id === record.id);
|
|
3680
|
+
if (index !== -1) {
|
|
3681
|
+
// Record is already in writeBuffer, update it
|
|
3682
|
+
this.writeBuffer[index] = record;
|
|
3683
|
+
} else {
|
|
3684
|
+
// Record is in file, add updated version to writeBuffer
|
|
3685
|
+
this.writeBuffer.push(record);
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
// Update index
|
|
3689
|
+
await this.indexManager.update(record, record, this.writeBuffer.length - 1);
|
|
3690
|
+
}
|
|
3691
|
+
if (this.opts.debugMode) {
|
|
3692
|
+
console.log(`🔄 ITERATE: Updated ${updateBuffer.length} records in ${Date.now() - startTime}ms`);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
// Process deletes
|
|
3697
|
+
if (deleteBuffer.size > 0) {
|
|
3698
|
+
for (const recordId of deleteBuffer) {
|
|
3699
|
+
// Find the record to get its data for term mapping removal
|
|
3700
|
+
const record = this.writeBuffer.find(r => r.id === recordId) || (await this.findOne({
|
|
3701
|
+
id: recordId
|
|
3702
|
+
}));
|
|
3703
|
+
if (record) {
|
|
3704
|
+
// Remove term mapping
|
|
3705
|
+
this.removeTermMapping(record);
|
|
3706
|
+
|
|
3707
|
+
// Remove from index
|
|
3708
|
+
await this.indexManager.remove(record);
|
|
3709
|
+
|
|
3710
|
+
// Remove from writeBuffer or mark as deleted
|
|
3711
|
+
const index = this.writeBuffer.findIndex(r => r.id === recordId);
|
|
3712
|
+
if (index !== -1) {
|
|
3713
|
+
this.writeBuffer.splice(index, 1);
|
|
3714
|
+
} else {
|
|
3715
|
+
// Mark as deleted if not in writeBuffer
|
|
3716
|
+
this.deletedIds.add(recordId);
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
if (this.opts.debugMode) {
|
|
3721
|
+
console.log(`🗑️ ITERATE: Deleted ${deleteBuffer.size} records in ${Date.now() - startTime}ms`);
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
// Auto-save if enabled
|
|
3726
|
+
if (options.autoSave) {
|
|
3727
|
+
await this.save();
|
|
3728
|
+
}
|
|
3729
|
+
this.shouldSave = true;
|
|
3730
|
+
this.performanceStats.operations++;
|
|
3731
|
+
} catch (error) {
|
|
3732
|
+
console.error('Batch processing failed:', error);
|
|
3733
|
+
throw error;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
/**
|
|
3738
|
+
* Close the database
|
|
3739
|
+
*/
|
|
3740
|
+
async close() {
|
|
3741
|
+
if (this.destroyed || this.closed) return;
|
|
3742
|
+
try {
|
|
3743
|
+
if (this.opts.debugMode) {
|
|
3744
|
+
console.log(`💾 close(): Saving and closing database (reopenable)`);
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
// 1. Save all pending data and index data to files
|
|
3748
|
+
if (this.writeBuffer.length > 0 || this.shouldSave) {
|
|
3749
|
+
await this.save();
|
|
3750
|
+
// Ensure writeBuffer is cleared after save
|
|
3751
|
+
if (this.writeBuffer.length > 0) {
|
|
3752
|
+
console.warn('⚠️ WriteBuffer not cleared after save() - forcing clear');
|
|
3753
|
+
this.writeBuffer = [];
|
|
3754
|
+
this.writeBufferOffsets = [];
|
|
3755
|
+
this.writeBufferSizes = [];
|
|
3756
|
+
}
|
|
3757
|
+
} else {
|
|
3758
|
+
// Even if no data to save, ensure index data is persisted
|
|
3759
|
+
await this._saveIndexDataToFile();
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
// 2. Mark as closed (but not destroyed) to allow reopening
|
|
3763
|
+
this.closed = true;
|
|
3764
|
+
this.initialized = false;
|
|
3765
|
+
|
|
3766
|
+
// 3. Clear any remaining state for clean reopening
|
|
3767
|
+
this.writeBuffer = [];
|
|
3768
|
+
this.writeBufferOffsets = [];
|
|
3769
|
+
this.writeBufferSizes = [];
|
|
3770
|
+
this.shouldSave = false;
|
|
3771
|
+
this.isSaving = false;
|
|
3772
|
+
this.lastSaveTime = null;
|
|
3773
|
+
if (this.opts.debugMode) {
|
|
3774
|
+
console.log(`💾 Database closed (can be reopened with init())`);
|
|
3775
|
+
}
|
|
3776
|
+
} catch (error) {
|
|
3777
|
+
console.error('Failed to close database:', error);
|
|
3778
|
+
// Mark as closed even if save failed
|
|
3779
|
+
this.closed = true;
|
|
3780
|
+
this.initialized = false;
|
|
3781
|
+
throw error;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
/**
|
|
3786
|
+
* Save index data to .idx.jdb file
|
|
3787
|
+
* @private
|
|
3788
|
+
*/
|
|
3789
|
+
async _saveIndexDataToFile() {
|
|
3790
|
+
if (this.indexManager) {
|
|
3791
|
+
try {
|
|
3792
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
3793
|
+
const indexData = {
|
|
3794
|
+
index: this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {},
|
|
3795
|
+
offsets: this.offsets,
|
|
3796
|
+
// Save actual offsets for efficient file operations
|
|
3797
|
+
indexOffset: this.indexOffset,
|
|
3798
|
+
// Save file size for proper range calculations
|
|
3799
|
+
// Save configuration for reuse when database exists
|
|
3800
|
+
config: {
|
|
3801
|
+
fields: this.opts.fields,
|
|
3802
|
+
indexes: this.opts.indexes,
|
|
3803
|
+
originalIndexes: this.opts.originalIndexes,
|
|
3804
|
+
schema: this.serializer?.getSchema?.() || null
|
|
3805
|
+
}
|
|
3806
|
+
};
|
|
3807
|
+
|
|
3808
|
+
// Include term mapping data in .idx file if term mapping fields exist
|
|
3809
|
+
const termMappingFields = this.getTermMappingFields();
|
|
3810
|
+
if (termMappingFields.length > 0 && this.termManager) {
|
|
3811
|
+
const termData = await this.termManager.saveTerms();
|
|
3812
|
+
indexData.termMapping = termData;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// Always create .idx file for databases with indexes, even if empty
|
|
3816
|
+
// This ensures the database structure is complete
|
|
3817
|
+
const originalFile = this.fileHandler.file;
|
|
3818
|
+
this.fileHandler.file = idxPath;
|
|
3819
|
+
await this.fileHandler.writeAll(JSON.stringify(indexData, null, 2));
|
|
3820
|
+
this.fileHandler.file = originalFile;
|
|
3821
|
+
if (this.opts.debugMode) {
|
|
3822
|
+
console.log(`💾 Index data saved to ${idxPath}`);
|
|
3823
|
+
}
|
|
3824
|
+
} catch (error) {
|
|
3825
|
+
console.warn('Failed to save index data:', error.message);
|
|
3826
|
+
throw error; // Re-throw to let caller handle
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
/**
|
|
3832
|
+
* Get operation queue statistics
|
|
3833
|
+
*/
|
|
3834
|
+
getQueueStats() {
|
|
3835
|
+
if (!this.operationQueue) {
|
|
3836
|
+
return {
|
|
3837
|
+
queueLength: 0,
|
|
3838
|
+
isProcessing: false,
|
|
3839
|
+
totalOperations: 0,
|
|
3840
|
+
completedOperations: 0,
|
|
3841
|
+
failedOperations: 0,
|
|
3842
|
+
successRate: 0,
|
|
3843
|
+
averageProcessingTime: 0,
|
|
3844
|
+
maxProcessingTime: 0
|
|
3845
|
+
};
|
|
3846
|
+
}
|
|
3847
|
+
return this.operationQueue.getStats();
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
/**
|
|
3851
|
+
* Wait for all pending operations to complete
|
|
3852
|
+
* This includes operation queue AND active insert sessions
|
|
3853
|
+
* If called with no arguments, interpret as waitForOperations(null).
|
|
3854
|
+
* If argument provided (maxWaitTime), pass that on.
|
|
3855
|
+
*/
|
|
3856
|
+
async waitForOperations(maxWaitTime = null) {
|
|
3857
|
+
// Accept any falsy/undefined/empty call as "wait for all"
|
|
3858
|
+
const actualWaitTime = arguments.length === 0 ? null : maxWaitTime;
|
|
3859
|
+
const startTime = Date.now();
|
|
3860
|
+
const hasTimeout = actualWaitTime !== null && actualWaitTime !== undefined;
|
|
3861
|
+
|
|
3862
|
+
// Wait for operation queue
|
|
3863
|
+
if (this.operationQueue) {
|
|
3864
|
+
const queueCompleted = await this.operationQueue.waitForCompletion(actualWaitTime);
|
|
3865
|
+
if (!queueCompleted && hasTimeout) {
|
|
3866
|
+
return false;
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
// Wait for active insert sessions
|
|
3871
|
+
if (this.activeInsertSessions.size > 0) {
|
|
3872
|
+
if (this.opts.debugMode) {
|
|
3873
|
+
console.log(`⏳ waitForOperations: Waiting for ${this.activeInsertSessions.size} active insert sessions`);
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
// Wait for all active sessions to complete
|
|
3877
|
+
const sessionPromises = Array.from(this.activeInsertSessions).map(session => session.waitForOperations(actualWaitTime));
|
|
3878
|
+
try {
|
|
3879
|
+
const sessionResults = await Promise.all(sessionPromises);
|
|
3880
|
+
|
|
3881
|
+
// Check if any session timed out
|
|
3882
|
+
if (hasTimeout && sessionResults.some(result => !result)) {
|
|
3883
|
+
return false;
|
|
3884
|
+
}
|
|
3885
|
+
} catch (error) {
|
|
3886
|
+
if (this.opts.debugMode) {
|
|
3887
|
+
console.log(`⚠️ waitForOperations: Error waiting for sessions: ${error.message}`);
|
|
3888
|
+
}
|
|
3889
|
+
// Continue anyway - don't fail the entire operation
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
return true;
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
exports.Database = Database;
|
|
3896
|
+
var _default = exports.default = Database;
|