@semics-tech/mongolite 1.0.0 → 1.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.
@@ -0,0 +1,1709 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MongoLiteCollection = void 0;
4
+ const bson_1 = require("bson");
5
+ const findCursor_js_1 = require("./cursors/findCursor.js");
6
+ const indexing_js_1 = require("./utils/indexing.js");
7
+ const changeStream_js_1 = require("./changeStream.js");
8
+ const projection_js_1 = require("./utils/projection.js");
9
+ const queryOperators_js_1 = require("./utils/queryOperators.js");
10
+ /**
11
+ * Safely stringifies JSON data with validation and error handling.
12
+ * Prevents storing malformed JSON that could cause parsing issues later.
13
+ * @param data The data to stringify
14
+ * @param context Optional context for error messages
15
+ * @returns The JSON string
16
+ * @throws Error if the data cannot be safely stringified
17
+ */
18
+ function safeJsonStringify(data, context) {
19
+ try {
20
+ // First, validate that the data is JSON-serializable by attempting to stringify it
21
+ const jsonString = JSON.stringify(data);
22
+ // Verify that the stringified data can be parsed back (round-trip test)
23
+ try {
24
+ const parsed = JSON.parse(jsonString);
25
+ // Basic validation that the round-trip preserved the data structure
26
+ if (typeof data === 'object' && data !== null && typeof parsed !== 'object') {
27
+ throw new Error('Round-trip JSON validation failed: type mismatch');
28
+ }
29
+ }
30
+ catch (parseError) {
31
+ throw new Error(`Round-trip JSON validation failed: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}`);
32
+ }
33
+ return jsonString;
34
+ }
35
+ catch (error) {
36
+ const contextMsg = context ? ` in ${context}` : '';
37
+ if (error instanceof Error) {
38
+ throw new Error(`Failed to safely stringify JSON data${contextMsg}: ${error.message}`);
39
+ }
40
+ throw new Error(`Failed to safely stringify JSON data${contextMsg}: Unknown error`);
41
+ }
42
+ }
43
+ /**
44
+ * Safely parses JSON data with fallback mechanisms for malformed JSON.
45
+ * @param jsonString The JSON string to parse
46
+ * @param context Optional context for error messages and recovery
47
+ * @returns The parsed object or a fallback object
48
+ */
49
+ function safeJsonParse(jsonString, context) {
50
+ if (!jsonString || typeof jsonString !== 'string') {
51
+ console.warn(`Invalid JSON string${context ? ` in ${context}` : ''}: not a string or empty`);
52
+ return {};
53
+ }
54
+ try {
55
+ return JSON.parse(jsonString);
56
+ }
57
+ catch (error) {
58
+ // Log the error for debugging
59
+ console.error(`JSON parse error${context ? ` in ${context}` : ''}: ${error instanceof Error ? error.message : 'Unknown error'}`);
60
+ console.error(`Malformed JSON string: ${jsonString.substring(0, 500)}${jsonString.length > 500 ? '...' : ''}`);
61
+ // Attempt to recover from common JSON corruption issues
62
+ try {
63
+ // Try to fix common escaping issues
64
+ let fixedJson = jsonString;
65
+ // Fix double-escaped quotes
66
+ fixedJson = fixedJson.replace(/\\"/g, '"');
67
+ // Fix improperly escaped backslashes
68
+ fixedJson = fixedJson.replace(/\\\\/g, '\\');
69
+ // Try parsing the fixed JSON
70
+ const recovered = JSON.parse(fixedJson);
71
+ console.warn(`Successfully recovered malformed JSON${context ? ` in ${context}` : ''}`);
72
+ return recovered;
73
+ }
74
+ catch (recoveryError) {
75
+ console.error(`JSON recovery failed${context ? ` in ${context}` : ''}: ${recoveryError instanceof Error ? recoveryError.message : 'Unknown error'}`);
76
+ // Last resort: return an empty object with a special marker
77
+ return {
78
+ __mongoLiteCorrupted: true,
79
+ __originalData: jsonString,
80
+ __error: error instanceof Error ? error.message : 'Unknown JSON parse error',
81
+ };
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Validates that a document is safe to store (no functions, circular references, etc.)
87
+ * @param doc The document to validate
88
+ * @param path Current path for error reporting
89
+ * @throws Error if the document contains invalid data
90
+ */
91
+ function validateDocumentForStorage(doc, path = 'root') {
92
+ if (doc === null || doc === undefined) {
93
+ return;
94
+ }
95
+ if (typeof doc === 'function') {
96
+ throw new Error(`Document validation failed: Functions are not allowed in documents (found at ${path})`);
97
+ }
98
+ if (typeof doc === 'symbol') {
99
+ throw new Error(`Document validation failed: Symbols are not allowed in documents (found at ${path})`);
100
+ }
101
+ if (typeof doc === 'bigint') {
102
+ throw new Error(`Document validation failed: BigInt values are not supported, use regular numbers or strings (found at ${path})`);
103
+ }
104
+ if (doc instanceof RegExp) {
105
+ throw new Error(`Document validation failed: RegExp objects are not supported in documents (found at ${path})`);
106
+ }
107
+ if (Array.isArray(doc)) {
108
+ doc.forEach((item, index) => {
109
+ validateDocumentForStorage(item, `${path}[${index}]`);
110
+ });
111
+ }
112
+ else if (typeof doc === 'object') {
113
+ // Check for circular references by maintaining a Set of visited objects
114
+ const visited = new Set();
115
+ function checkCircular(obj, currentPath) {
116
+ if (visited.has(obj)) {
117
+ throw new Error(`Document validation failed: Circular reference detected at ${currentPath}`);
118
+ }
119
+ visited.add(obj);
120
+ for (const [key, value] of Object.entries(obj)) {
121
+ if (typeof value === 'object' && value !== null) {
122
+ checkCircular(value, `${currentPath}.${key}`);
123
+ }
124
+ }
125
+ visited.delete(obj);
126
+ }
127
+ try {
128
+ checkCircular(doc, path);
129
+ }
130
+ catch (error) {
131
+ if (error instanceof Error && error.message.includes('Circular reference')) {
132
+ throw error;
133
+ }
134
+ }
135
+ // Validate each property
136
+ for (const [key, value] of Object.entries(doc)) {
137
+ validateDocumentForStorage(value, `${path}.${key}`);
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * MongoLiteCollection provides methods to interact with a specific SQLite table
143
+ * as if it were a MongoDB collection.
144
+ */
145
+ class MongoLiteCollection {
146
+ constructor(db, name, options = {}) {
147
+ this.db = db;
148
+ this.name = name;
149
+ this.options = options;
150
+ this.ensureTable().catch((err) => {
151
+ // This error should be handled or logged appropriately.
152
+ // For now, console.error is used. In a real app, a more robust
153
+ // error handling mechanism would be needed, potentially failing
154
+ // the collection initialization or notifying the user.
155
+ console.error(`Failed to ensure table ${this.name} exists:`, err);
156
+ });
157
+ }
158
+ /**
159
+ * Ensures the SQLite table for this collection exists, creating it if necessary.
160
+ * The table will have an `_id` column (indexed) and a `data` column for JSON.
161
+ * @private
162
+ */
163
+ async ensureTable() {
164
+ // Using " " around table name to handle names with special characters or keywords
165
+ const createTableSQL = `
166
+ CREATE TABLE IF NOT EXISTS "${this.name}" (
167
+ _id TEXT PRIMARY KEY,
168
+ data TEXT
169
+ );
170
+ `;
171
+ // It's generally good practice to create an index on _id, but TEXT PRIMARY KEY often implies an index.
172
+ // Explicitly creating an index can be done if performance dictates.
173
+ // const createIndexSQL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.name}__id ON "${this.name}"(_id);`;
174
+ try {
175
+ await this.db.exec(createTableSQL);
176
+ // await this.db.exec(createIndexSQL); // If explicit index is desired
177
+ }
178
+ catch (error) {
179
+ console.error(`Error ensuring table "${this.name}":`, error);
180
+ throw error; // Re-throw to allow caller to handle
181
+ }
182
+ }
183
+ /**
184
+ * Inserts a single document into the collection.
185
+ * If `_id` is not provided, a UUID will be generated.
186
+ * @param doc The document to insert.
187
+ * @returns {Promise<InsertOneResult>} An object containing the outcome of the insert operation.
188
+ */
189
+ async insertOne(doc) {
190
+ await this.ensureTable(); // Ensure table exists before insert
191
+ const docId = doc._id || new bson_1.ObjectId().toString();
192
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
193
+ const { _id, ...dataToStore } = { ...doc, _id: docId }; // Ensure _id is part of the internal structure
194
+ // Validate the document before storing
195
+ try {
196
+ validateDocumentForStorage(dataToStore);
197
+ }
198
+ catch (error) {
199
+ throw new Error(`Cannot insert document: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
200
+ }
201
+ const jsonData = safeJsonStringify(dataToStore, `insertOne for document ${docId}`);
202
+ const sql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
203
+ try {
204
+ await this.db.run(sql, [docId, jsonData]);
205
+ return { acknowledged: true, insertedId: docId };
206
+ }
207
+ catch (error) {
208
+ // Handle potential errors, e.g., unique constraint violation if _id already exists
209
+ console.error(`Error inserting document into ${this.name}:`, error);
210
+ // Check for unique constraint error (SQLite specific error code)
211
+ if (error.code === 'SQLITE_CONSTRAINT') {
212
+ throw new Error(`Duplicate _id: ${docId}`);
213
+ }
214
+ // Handle SQLITE_BUSY
215
+ if (error.code === 'SQLITE_BUSY') {
216
+ return this.retryWithBackoff(() => this.insertOne(doc), `insert for ${docId}`);
217
+ }
218
+ throw error; // Re-throw other errors
219
+ }
220
+ }
221
+ /**
222
+ * Inserts multiple documents into the collection.
223
+ * If any document does not have an `_id`, a UUID will be generated.
224
+ * Uses batch insert with transactions for improved performance.
225
+ * @param docs An array of documents to insert.
226
+ * @returns {Promise<InsertManyResult>} An object containing the outcome of all insert operations.
227
+ * */
228
+ async insertMany(docs) {
229
+ await this.ensureTable(); // Ensure table exists before insert
230
+ if (docs.length === 0) {
231
+ return { acknowledged: true, insertedCount: 0, insertedIds: {} };
232
+ }
233
+ const insertedIds = {};
234
+ const batchSize = 500; // Process in batches to avoid memory issues with very large datasets
235
+ let insertedCount = 0;
236
+ let index = 0;
237
+ // Process documents in batches
238
+ for (let i = 0; i < docs.length; i += batchSize) {
239
+ const batch = docs.slice(i, i + batchSize);
240
+ const batchResults = await this.insertBatch(batch);
241
+ batchResults.forEach((res) => {
242
+ insertedIds[index++] = res.insertedId;
243
+ });
244
+ insertedCount += batchResults.length;
245
+ }
246
+ return { acknowledged: true, insertedCount, insertedIds };
247
+ }
248
+ /**
249
+ * Inserts a batch of documents using a single transaction for optimal performance.
250
+ * @private
251
+ */
252
+ async insertBatch(docs) {
253
+ const results = [];
254
+ // Prepare all documents and their data upfront
255
+ const preparedDocs = docs.map((doc) => {
256
+ const docId = doc._id || new bson_1.ObjectId().toString();
257
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
258
+ const { _id, ...dataToStore } = { ...doc, _id: docId };
259
+ // Validate the document before storing
260
+ try {
261
+ validateDocumentForStorage(dataToStore);
262
+ }
263
+ catch (error) {
264
+ throw new Error(`Cannot insert document ${docId}: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
265
+ }
266
+ return {
267
+ id: docId,
268
+ data: safeJsonStringify(dataToStore, `insertBatch for document ${docId}`),
269
+ };
270
+ });
271
+ const insertBatchOperation = async () => {
272
+ // Begin transaction
273
+ await this.db.exec('BEGIN TRANSACTION');
274
+ try {
275
+ const sql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
276
+ // Use a prepared statement for better performance
277
+ for (const preparedDoc of preparedDocs) {
278
+ await this.db.run(sql, [preparedDoc.id, preparedDoc.data]);
279
+ results.push({ acknowledged: true, insertedId: preparedDoc.id });
280
+ }
281
+ // Commit the transaction
282
+ await this.db.exec('COMMIT');
283
+ }
284
+ catch (error) {
285
+ // Rollback on any error
286
+ try {
287
+ await this.db.exec('ROLLBACK');
288
+ }
289
+ catch (rollbackError) {
290
+ console.error(`Error during rollback in ${this.name}:`, rollbackError);
291
+ }
292
+ // Clear results since transaction failed
293
+ results.length = 0;
294
+ // Handle specific error types
295
+ if (error.code === 'SQLITE_CONSTRAINT') {
296
+ // Find which document caused the constraint violation
297
+ const errorMessage = error.message;
298
+ const duplicateId = preparedDocs.find((doc) => errorMessage.includes(doc.id))?.id || 'unknown';
299
+ throw new Error(`Duplicate _id: ${duplicateId}`);
300
+ }
301
+ throw error;
302
+ }
303
+ };
304
+ try {
305
+ await insertBatchOperation();
306
+ }
307
+ catch (error) {
308
+ if (error.code === 'SQLITE_BUSY') {
309
+ // Retry the entire batch with backoff
310
+ await this.retryWithBackoff(insertBatchOperation, `batch insert of ${docs.length} documents`);
311
+ }
312
+ else {
313
+ throw error;
314
+ }
315
+ }
316
+ return results;
317
+ }
318
+ /**
319
+ * Finds a single document matching the filter.
320
+ * @param filter The query criteria.
321
+ * @param projection Optional. Specifies the fields to return.
322
+ * @returns {Promise<T | null>} The found document or `null`.
323
+ * @remarks When using a projection, some fields may be undefined at runtime despite the
324
+ * return type being `T`. This provides better ergonomics for the common case where no
325
+ * projection is used.
326
+ */
327
+ async findOne(filter, projection) {
328
+ await this.ensureTable();
329
+ const cursor = this.find(filter).limit(1);
330
+ if (projection) {
331
+ cursor.project(projection);
332
+ }
333
+ const results = await cursor.toArray();
334
+ return results.length > 0 ? results[0] : null;
335
+ }
336
+ /**
337
+ * Finds multiple documents matching the filter and returns a cursor.
338
+ * @param filter The query criteria.
339
+ * @returns {FindCursor<T>} A `FindCursor` instance.
340
+ */
341
+ find(filter = {}) {
342
+ // ensureTable is called by operations on the cursor or by constructor
343
+ return new findCursor_js_1.FindCursor(this.db, this.name, filter, this.options);
344
+ }
345
+ /**
346
+ * Updates a single document matching the filter.
347
+ * @param filter The selection criteria for the update.
348
+ * @param update The modifications to apply.
349
+ * @returns {Promise<UpdateResult>} An object describing the outcome.
350
+ */
351
+ async updateOne(filter, update, options = {}) {
352
+ await this.ensureTable();
353
+ // Find the document first (only one)
354
+ const paramsForSelect = [];
355
+ const whereClause = new findCursor_js_1.FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForSelect);
356
+ const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
357
+ const rowToUpdate = await this.db.get(selectSql, paramsForSelect);
358
+ if (!rowToUpdate && !options.upsert) {
359
+ // If no document found and upsert is not requested, return no changes
360
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
361
+ }
362
+ // If no document found and upsert is requested, create a new document
363
+ if (!rowToUpdate && options.upsert) {
364
+ // Check if _id is a valid ObjectId or provided
365
+ if (update.$set?._id && !bson_1.ObjectId.isValid(update.$set._id)) {
366
+ throw new Error(`_id must be a valid ObjectId or not provided for upsert in ${this.name}`);
367
+ }
368
+ // Generate a new _id if not provided
369
+ // If _id is provided in $set, use it; otherwise, generate a new ObjectId
370
+ const newDocId = update.$set?._id || new bson_1.ObjectId().toString();
371
+ const newDoc = { _id: newDocId, ...update.$set }; // Assuming $set is used for upsert
372
+ // Ensure _id is set
373
+ const jsonData = safeJsonStringify(newDoc, `upsert for document ${newDocId}`);
374
+ const insertSql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
375
+ try {
376
+ await this.db.run(insertSql, [newDocId, jsonData]);
377
+ return {
378
+ acknowledged: true,
379
+ matchedCount: 0,
380
+ modifiedCount: 0,
381
+ upsertedId: newDocId,
382
+ };
383
+ }
384
+ catch (error) {
385
+ console.error(`Error inserting document during upsert in ${this.name}:`, error);
386
+ if (error.code === 'SQLITE_CONSTRAINT') {
387
+ throw new Error(`Duplicate _id during upsert: ${newDocId}`);
388
+ }
389
+ throw error; // Re-throw other errors
390
+ }
391
+ }
392
+ if (!rowToUpdate) {
393
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
394
+ }
395
+ const currentDoc = safeJsonParse(rowToUpdate.data, `updateOne for document ${rowToUpdate._id}`);
396
+ let modified = false;
397
+ // Process update operators
398
+ for (const operator in update) {
399
+ const opArgs = update[operator];
400
+ if (!opArgs)
401
+ continue;
402
+ switch (operator) {
403
+ case '$set':
404
+ for (const path in opArgs) {
405
+ const value = opArgs[path];
406
+ this.setNestedValue(currentDoc, path, value);
407
+ modified = true;
408
+ }
409
+ break;
410
+ case '$unset':
411
+ for (const path in opArgs) {
412
+ this.unsetNestedValue(currentDoc, path);
413
+ modified = true;
414
+ }
415
+ break;
416
+ case '$inc':
417
+ for (const path in opArgs) {
418
+ const value = opArgs[path];
419
+ if (typeof value === 'number') {
420
+ const currentValue = this.getNestedValue(currentDoc, path) || 0;
421
+ if (typeof currentValue === 'number') {
422
+ this.setNestedValue(currentDoc, path, currentValue + value);
423
+ modified = true;
424
+ }
425
+ }
426
+ }
427
+ break;
428
+ case '$push':
429
+ for (const path in opArgs) {
430
+ const value = opArgs[path];
431
+ const currentValue = this.getNestedValue(currentDoc, path);
432
+ if (Array.isArray(currentValue)) {
433
+ if (typeof value === 'object' && value !== null && '$each' in value) {
434
+ if (Array.isArray(value.$each)) {
435
+ currentValue.push(...value.$each);
436
+ modified = true;
437
+ }
438
+ }
439
+ else {
440
+ currentValue.push(value);
441
+ modified = true;
442
+ }
443
+ }
444
+ else if (currentValue === undefined) {
445
+ // If the field doesn't exist, create it as an array
446
+ if (typeof value === 'object' && value !== null && '$each' in value) {
447
+ if (Array.isArray(value.$each)) {
448
+ this.setNestedValue(currentDoc, path, [...value.$each]);
449
+ modified = true;
450
+ }
451
+ }
452
+ else {
453
+ this.setNestedValue(currentDoc, path, [value]);
454
+ modified = true;
455
+ }
456
+ }
457
+ }
458
+ break;
459
+ case '$pull':
460
+ for (const path in opArgs) {
461
+ const value = opArgs[path];
462
+ const currentValue = this.getNestedValue(currentDoc, path);
463
+ if (Array.isArray(currentValue)) {
464
+ // Simple equality pull
465
+ const newArray = currentValue.filter((item) => {
466
+ if (typeof item === 'object' && typeof value === 'object') {
467
+ // For objects, do a deep comparison (simplified)
468
+ return JSON.stringify(item) !== JSON.stringify(value);
469
+ }
470
+ return item !== value;
471
+ });
472
+ if (newArray.length !== currentValue.length) {
473
+ this.setNestedValue(currentDoc, path, newArray);
474
+ modified = true;
475
+ }
476
+ }
477
+ }
478
+ break;
479
+ case '$addToSet':
480
+ for (const path in opArgs) {
481
+ const value = opArgs[path];
482
+ const currentValue = this.getNestedValue(currentDoc, path);
483
+ const arr = Array.isArray(currentValue) ? currentValue : [];
484
+ const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
485
+ ? value.$each
486
+ : [value];
487
+ let changed = false;
488
+ for (const item of items) {
489
+ const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
490
+ if (!alreadyPresent) {
491
+ arr.push(item);
492
+ changed = true;
493
+ }
494
+ }
495
+ if (changed || !Array.isArray(currentValue)) {
496
+ this.setNestedValue(currentDoc, path, arr);
497
+ modified = true;
498
+ }
499
+ }
500
+ break;
501
+ case '$pop':
502
+ for (const path in opArgs) {
503
+ const value = opArgs[path];
504
+ const currentValue = this.getNestedValue(currentDoc, path);
505
+ if (Array.isArray(currentValue) && currentValue.length > 0) {
506
+ if (value === 1) {
507
+ currentValue.pop();
508
+ }
509
+ else if (value === -1) {
510
+ currentValue.shift();
511
+ }
512
+ this.setNestedValue(currentDoc, path, currentValue);
513
+ modified = true;
514
+ }
515
+ }
516
+ break;
517
+ case '$mul':
518
+ for (const path in opArgs) {
519
+ const value = opArgs[path];
520
+ if (typeof value === 'number') {
521
+ const currentValue = this.getNestedValue(currentDoc, path);
522
+ const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
523
+ this.setNestedValue(currentDoc, path, numericCurrent * value);
524
+ modified = true;
525
+ }
526
+ }
527
+ break;
528
+ case '$min':
529
+ for (const path in opArgs) {
530
+ const value = opArgs[path];
531
+ const currentValue = this.getNestedValue(currentDoc, path);
532
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
533
+ if (currentValue === undefined || value < currentValue) {
534
+ this.setNestedValue(currentDoc, path, value);
535
+ modified = true;
536
+ }
537
+ }
538
+ break;
539
+ case '$max':
540
+ for (const path in opArgs) {
541
+ const value = opArgs[path];
542
+ const currentValue = this.getNestedValue(currentDoc, path);
543
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
544
+ if (currentValue === undefined || value > currentValue) {
545
+ this.setNestedValue(currentDoc, path, value);
546
+ modified = true;
547
+ }
548
+ }
549
+ break;
550
+ case '$currentDate':
551
+ for (const path in opArgs) {
552
+ const value = opArgs[path];
553
+ if (value === true) {
554
+ // Default: store as ISO string date
555
+ this.setNestedValue(currentDoc, path, new Date().toISOString());
556
+ modified = true;
557
+ continue;
558
+ }
559
+ if (value && typeof value === 'object' && '$type' in value) {
560
+ const typeValue = value.$type;
561
+ if (typeValue === 'date') {
562
+ this.setNestedValue(currentDoc, path, new Date().toISOString());
563
+ modified = true;
564
+ continue;
565
+ }
566
+ if (typeValue === 'timestamp') {
567
+ // Represent timestamp as a numeric UNIX epoch in milliseconds
568
+ this.setNestedValue(currentDoc, path, Date.now());
569
+ modified = true;
570
+ continue;
571
+ }
572
+ throw new Error(`Unsupported $currentDate $type value for path "${path}": ${JSON.stringify(typeValue)}`);
573
+ }
574
+ throw new Error(`Unsupported $currentDate value for path "${path}": ${JSON.stringify(value)}`);
575
+ }
576
+ break;
577
+ }
578
+ }
579
+ if (modified) {
580
+ // Update the document in SQLite
581
+ const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
582
+ const updateParams = [
583
+ safeJsonStringify(currentDoc, `updateOne for document ${rowToUpdate._id}`),
584
+ rowToUpdate._id,
585
+ ];
586
+ try {
587
+ await this.db.run(updateSql, updateParams);
588
+ }
589
+ catch (error) {
590
+ if (error.code === 'SQLITE_BUSY') {
591
+ await this.retryWithBackoff(async () => this.db.run(updateSql, updateParams), `update for ${rowToUpdate._id}`);
592
+ }
593
+ else {
594
+ throw error;
595
+ }
596
+ }
597
+ return {
598
+ acknowledged: true,
599
+ matchedCount: 1,
600
+ modifiedCount: 1, // Since we found and modified exactly one document
601
+ upsertedId: null,
602
+ };
603
+ }
604
+ return {
605
+ acknowledged: true,
606
+ matchedCount: 1,
607
+ modifiedCount: 0, // No changes were made
608
+ upsertedId: null,
609
+ };
610
+ }
611
+ /**
612
+ * Updates multiple documents matching the filter.
613
+ * @param filter The selection criteria for the update.
614
+ * @param update The modifications to apply.
615
+ * @returns {Promise<UpdateResult>} An object describing the outcome.
616
+ */
617
+ async updateMany(filter, update) {
618
+ await this.ensureTable();
619
+ const paramsForSelect = [];
620
+ const whereClause = new findCursor_js_1.FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForSelect);
621
+ const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause}`;
622
+ const rowsToUpdate = await this.db.all(selectSql, paramsForSelect);
623
+ if (rowsToUpdate.length === 0) {
624
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
625
+ }
626
+ let modifiedCount = 0;
627
+ const updatedIds = [];
628
+ for (const rowToUpdate of rowsToUpdate) {
629
+ const currentDoc = safeJsonParse(rowToUpdate.data, `updateMany for document ${rowToUpdate._id}`);
630
+ let modified = false;
631
+ // Process update operators
632
+ for (const operator in update) {
633
+ const opArgs = update[operator];
634
+ if (!opArgs)
635
+ continue;
636
+ switch (operator) {
637
+ case '$set':
638
+ for (const path in opArgs) {
639
+ const value = opArgs[path];
640
+ this.setNestedValue(currentDoc, path, value);
641
+ modified = true;
642
+ }
643
+ break;
644
+ case '$unset':
645
+ for (const path in opArgs) {
646
+ this.unsetNestedValue(currentDoc, path);
647
+ modified = true;
648
+ }
649
+ break;
650
+ case '$inc':
651
+ for (const path in opArgs) {
652
+ const value = opArgs[path];
653
+ if (typeof value === 'number') {
654
+ const currentValue = this.getNestedValue(currentDoc, path) || 0;
655
+ if (typeof currentValue === 'number') {
656
+ this.setNestedValue(currentDoc, path, currentValue + value);
657
+ modified = true;
658
+ }
659
+ }
660
+ }
661
+ break;
662
+ case '$push':
663
+ for (const path in opArgs) {
664
+ const value = opArgs[path];
665
+ const currentValue = this.getNestedValue(currentDoc, path);
666
+ if (Array.isArray(currentValue)) {
667
+ if (typeof value === 'object' && value !== null && '$each' in value) {
668
+ if (Array.isArray(value.$each)) {
669
+ currentValue.push(...value.$each);
670
+ modified = true;
671
+ }
672
+ }
673
+ else {
674
+ currentValue.push(value);
675
+ modified = true;
676
+ }
677
+ }
678
+ else if (currentValue === undefined) {
679
+ // If the field doesn't exist, create it as an array
680
+ if (typeof value === 'object' && value !== null && '$each' in value) {
681
+ if (Array.isArray(value.$each)) {
682
+ this.setNestedValue(currentDoc, path, [...value.$each]);
683
+ modified = true;
684
+ }
685
+ }
686
+ else {
687
+ this.setNestedValue(currentDoc, path, [value]);
688
+ modified = true;
689
+ }
690
+ }
691
+ }
692
+ break;
693
+ case '$pull':
694
+ for (const path in opArgs) {
695
+ const value = opArgs[path];
696
+ const currentValue = this.getNestedValue(currentDoc, path);
697
+ if (Array.isArray(currentValue)) {
698
+ // Simple equality pull
699
+ const newArray = currentValue.filter((item) => {
700
+ if (typeof item === 'object' && typeof value === 'object') {
701
+ // For objects, do a deep comparison (simplified)
702
+ return JSON.stringify(item) !== JSON.stringify(value);
703
+ }
704
+ return item !== value;
705
+ });
706
+ if (newArray.length !== currentValue.length) {
707
+ this.setNestedValue(currentDoc, path, newArray);
708
+ modified = true;
709
+ }
710
+ }
711
+ }
712
+ break;
713
+ case '$addToSet':
714
+ for (const path in opArgs) {
715
+ const value = opArgs[path];
716
+ const currentValue = this.getNestedValue(currentDoc, path);
717
+ const arr = Array.isArray(currentValue) ? currentValue : [];
718
+ const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
719
+ ? value.$each
720
+ : [value];
721
+ let changed = false;
722
+ for (const item of items) {
723
+ const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
724
+ if (!alreadyPresent) {
725
+ arr.push(item);
726
+ changed = true;
727
+ }
728
+ }
729
+ if (changed || !Array.isArray(currentValue)) {
730
+ this.setNestedValue(currentDoc, path, arr);
731
+ modified = true;
732
+ }
733
+ }
734
+ break;
735
+ case '$pop':
736
+ for (const path in opArgs) {
737
+ const value = opArgs[path];
738
+ const currentValue = this.getNestedValue(currentDoc, path);
739
+ if (Array.isArray(currentValue) && currentValue.length > 0) {
740
+ if (value === 1) {
741
+ currentValue.pop();
742
+ }
743
+ else if (value === -1) {
744
+ currentValue.shift();
745
+ }
746
+ this.setNestedValue(currentDoc, path, currentValue);
747
+ modified = true;
748
+ }
749
+ }
750
+ break;
751
+ case '$mul':
752
+ for (const path in opArgs) {
753
+ const value = opArgs[path];
754
+ if (typeof value === 'number') {
755
+ const currentValue = this.getNestedValue(currentDoc, path);
756
+ const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
757
+ this.setNestedValue(currentDoc, path, numericCurrent * value);
758
+ modified = true;
759
+ }
760
+ }
761
+ break;
762
+ case '$min':
763
+ for (const path in opArgs) {
764
+ const value = opArgs[path];
765
+ const currentValue = this.getNestedValue(currentDoc, path);
766
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
767
+ if (currentValue === undefined || value < currentValue) {
768
+ this.setNestedValue(currentDoc, path, value);
769
+ modified = true;
770
+ }
771
+ }
772
+ break;
773
+ case '$max':
774
+ for (const path in opArgs) {
775
+ const value = opArgs[path];
776
+ const currentValue = this.getNestedValue(currentDoc, path);
777
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
778
+ if (currentValue === undefined || value > currentValue) {
779
+ this.setNestedValue(currentDoc, path, value);
780
+ modified = true;
781
+ }
782
+ }
783
+ break;
784
+ case '$currentDate':
785
+ for (const path in opArgs) {
786
+ this.setNestedValue(currentDoc, path, new Date().toISOString());
787
+ modified = true;
788
+ }
789
+ break;
790
+ default:
791
+ throw new Error(`Unsupported update operator: ${operator}`);
792
+ }
793
+ }
794
+ if (modified) {
795
+ // Update the document in SQLite
796
+ const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
797
+ const updateParams = [
798
+ safeJsonStringify(currentDoc, `updateMany for document ${rowToUpdate._id}`),
799
+ rowToUpdate._id,
800
+ ];
801
+ try {
802
+ await this.db.run(updateSql, updateParams);
803
+ modifiedCount++;
804
+ updatedIds.push(rowToUpdate._id);
805
+ }
806
+ catch (error) {
807
+ if (error.code === 'SQLITE_BUSY') {
808
+ await this.retryWithBackoff(async () => {
809
+ await this.db.run(updateSql, updateParams);
810
+ modifiedCount++;
811
+ updatedIds.push(rowToUpdate._id);
812
+ }, `update for ${rowToUpdate._id} in batch operation`);
813
+ }
814
+ else {
815
+ throw error;
816
+ }
817
+ }
818
+ }
819
+ }
820
+ return {
821
+ acknowledged: true,
822
+ matchedCount: rowsToUpdate.length,
823
+ modifiedCount: modifiedCount,
824
+ upsertedId: null, // We don't support upsert yet
825
+ };
826
+ }
827
+ /**
828
+ * Creates an index on the specified field(s) of the collection.
829
+ *
830
+ * @param fieldOrSpec The field or specification to create the index on.
831
+ * @param options Options for creating the index.
832
+ * @returns A promise that resolves to the result of the operation.
833
+ *
834
+ * @example
835
+ * // Create a simple index on the "name" field
836
+ * collection.createIndex({ name: 1 });
837
+ *
838
+ * // Create a unique index on the "email" field
839
+ * collection.createIndex({ email: 1 }, { unique: true });
840
+ *
841
+ * // Create a compound index on multiple fields
842
+ * collection.createIndex({ name: 1, age: -1 });
843
+ */
844
+ async createIndex(fieldOrSpec, options = {}) {
845
+ await this.ensureTable();
846
+ // Convert string field to index spec format
847
+ const spec = typeof fieldOrSpec === 'string' ? { [fieldOrSpec]: 1 } : fieldOrSpec;
848
+ // Generate index name if not provided
849
+ const indexName = options.name || this.generateIndexName(spec);
850
+ // Build the index fields part for SQLite (field1, field2, ...)
851
+ const indexFields = Object.entries(spec)
852
+ .map(([field, direction]) => {
853
+ // For JSON field paths, we need to use json_extract
854
+ if (field.includes('.') || field !== '_id') {
855
+ const jsonPath = this.parseJsonPath(field);
856
+ return `json_extract(data, ${jsonPath}) ${direction === -1 ? 'DESC' : 'ASC'}`;
857
+ }
858
+ // For _id field, use the column directly
859
+ return `_id ${direction === -1 ? 'DESC' : 'ASC'}`;
860
+ })
861
+ .join(', ');
862
+ // Create the SQL for the index
863
+ const uniqueClause = options.unique ? 'UNIQUE' : '';
864
+ const createIndexSQL = `CREATE ${uniqueClause} INDEX IF NOT EXISTS "${indexName}" ON "${this.name}" (${indexFields})`;
865
+ try {
866
+ if (this.options.verbose) {
867
+ console.log(`Creating index ${indexName} on collection ${this.name}`);
868
+ }
869
+ await this.db.exec(createIndexSQL);
870
+ return { acknowledged: true, name: indexName };
871
+ }
872
+ catch (error) {
873
+ console.error(`Error creating index ${indexName} on collection ${this.name}:`, error);
874
+ throw error;
875
+ }
876
+ }
877
+ /**
878
+ * Generate a MongoDB-like index name from an index specification.
879
+ * Format: collectionName_field1_1_field2_-1
880
+ */
881
+ generateIndexName(spec) {
882
+ const nameParts = Object.entries(spec).map(([field, direction]) => `${field.replace(/\./g, '_')}_${direction}`);
883
+ return `${this.name}_${nameParts.join('_')}`;
884
+ }
885
+ /**
886
+ * Lists all indexes on the collection.
887
+ *
888
+ * @returns A promise that resolves to an object with a toArray method
889
+ * that returns the list of indexes.
890
+ */
891
+ listIndexes() {
892
+ return {
893
+ toArray: async () => {
894
+ await this.ensureTable();
895
+ try {
896
+ // Query SQLite master table for indexes on this collection
897
+ const indexes = await this.db.all(`SELECT name, sql FROM sqlite_master
898
+ WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_autoindex%'`, [this.name]);
899
+ const results = [];
900
+ // Parse the SQL to determine indexed fields and options
901
+ for (const index of indexes) {
902
+ const indexInfo = {
903
+ name: index.name,
904
+ key: {},
905
+ };
906
+ // Parse the CREATE INDEX statement to extract field information
907
+ // Example: CREATE UNIQUE INDEX "users_email_1" ON "users" (json_extract(data, '$.email') ASC)
908
+ const sqlLower = index.sql.toLowerCase();
909
+ indexInfo.unique = sqlLower.includes('unique index');
910
+ // Extract the fields from the SQL
911
+ const rawColumns = (0, indexing_js_1.extractRawIndexColumns)(index.sql);
912
+ for (const col of rawColumns) {
913
+ // Use the column name as the key, and direction as value
914
+ indexInfo.key[col.column] = col.direction === 'DESC' ? -1 : 1;
915
+ }
916
+ results.push(indexInfo);
917
+ }
918
+ return results;
919
+ }
920
+ catch (error) {
921
+ console.error(`Error listing indexes for collection ${this.name}:`, error);
922
+ throw error;
923
+ }
924
+ },
925
+ };
926
+ }
927
+ /**
928
+ * Drops a specified index from the collection.
929
+ *
930
+ * @param indexName The name of the index to drop.
931
+ * @returns A promise that resolves to the result of the operation.
932
+ */
933
+ async dropIndex(indexName) {
934
+ await this.ensureTable();
935
+ try {
936
+ if (this.options.verbose) {
937
+ console.log(`Dropping index ${indexName} from collection ${this.name}`);
938
+ }
939
+ await this.db.exec(`DROP INDEX IF EXISTS "${indexName}"`);
940
+ return { acknowledged: true, name: indexName };
941
+ }
942
+ catch (error) {
943
+ console.error(`Error dropping index ${indexName} from collection ${this.name}:`, error);
944
+ throw error;
945
+ }
946
+ }
947
+ /**
948
+ * Drops all indexes from the collection, except for the index on _id.
949
+ *
950
+ * @returns A promise that resolves to the result of the operation.
951
+ */
952
+ async dropIndexes() {
953
+ await this.ensureTable();
954
+ try {
955
+ const indexes = await this.listIndexes().toArray();
956
+ let droppedCount = 0;
957
+ for (const index of indexes) {
958
+ // Skip primary key index on _id if it exists
959
+ if (index.name === `sqlite_autoindex_${this.name}_1`)
960
+ continue;
961
+ await this.db.exec(`DROP INDEX IF EXISTS "${index.name}"`);
962
+ droppedCount++;
963
+ }
964
+ return { acknowledged: true, droppedCount };
965
+ }
966
+ catch (error) {
967
+ console.error(`Error dropping indexes from collection ${this.name}:`, error);
968
+ throw error;
969
+ }
970
+ }
971
+ /**
972
+ * Prepends '$.' to the path for SQLite JSON functions.
973
+ * @private
974
+ * @param path Field path, e.g., 'address.city'
975
+ * @returns JSON path string for SQLite, e.g., '$.address.city'
976
+ */
977
+ parseJsonPath(path) {
978
+ // Prepends '$.' to the path for SQLite JSON functions.
979
+ // 'address.city' becomes '$.address.city'
980
+ return `'$.${path}'`;
981
+ }
982
+ // Helper for $set, $inc to handle dot notation
983
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
984
+ setNestedValue(obj, path, value) {
985
+ const keys = path.split('.');
986
+ let current = obj;
987
+ // Navigate to the last parent in the path
988
+ for (let i = 0; i < keys.length - 1; i++) {
989
+ const key = keys[i];
990
+ // Create nested objects if they don't exist
991
+ if (current[key] === undefined || current[key] === null) {
992
+ current[key] = {};
993
+ }
994
+ else if (typeof current[key] !== 'object') {
995
+ // If it's not an object but we need to go deeper, replace it with an object
996
+ current[key] = {};
997
+ }
998
+ current = current[key];
999
+ }
1000
+ // Set the value at the final key
1001
+ current[keys[keys.length - 1]] = value;
1002
+ }
1003
+ // Helper for $unset
1004
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1005
+ unsetNestedValue(obj, path) {
1006
+ const keys = path.split('.');
1007
+ let current = obj;
1008
+ // Navigate to the last parent in the path
1009
+ for (let i = 0; i < keys.length - 1; i++) {
1010
+ const key = keys[i];
1011
+ if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
1012
+ return; // Path doesn't exist, nothing to unset
1013
+ }
1014
+ current = current[key];
1015
+ }
1016
+ // Delete the property at the final key
1017
+ delete current[keys[keys.length - 1]];
1018
+ }
1019
+ // Helper to get nested value
1020
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1021
+ getNestedValue(obj, path) {
1022
+ const keys = path.split('.');
1023
+ let current = obj;
1024
+ // Navigate through the path
1025
+ for (let i = 0; i < keys.length; i++) {
1026
+ const key = keys[i];
1027
+ if (current[key] === undefined) {
1028
+ return undefined; // Path doesn't exist
1029
+ }
1030
+ current = current[key];
1031
+ }
1032
+ return current;
1033
+ }
1034
+ /**
1035
+ * Deletes a single document matching the filter.
1036
+ * @param filter The criteria to select the document to delete.
1037
+ * @returns {Promise<DeleteResult>} An object describing the outcome.
1038
+ */
1039
+ async deleteOne(filter) {
1040
+ await this.ensureTable();
1041
+ const paramsForDelete = [];
1042
+ const whereClause = new findCursor_js_1.FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForDelete);
1043
+ // Get the number of documents that would be deleted
1044
+ const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
1045
+ const countResult = await this.db.get(countSql, paramsForDelete);
1046
+ const deleteSql = `
1047
+ DELETE FROM "${this.name}"
1048
+ WHERE ROWID IN (SELECT ROWID FROM "${this.name}" WHERE ${whereClause} LIMIT 1);
1049
+ `;
1050
+ try {
1051
+ await this.db.run(deleteSql, paramsForDelete);
1052
+ }
1053
+ catch (error) {
1054
+ if (error.code === 'SQLITE_BUSY') {
1055
+ await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete for filter ${JSON.stringify(filter)}`);
1056
+ }
1057
+ else {
1058
+ throw error;
1059
+ }
1060
+ }
1061
+ return { acknowledged: true, deletedCount: countResult?.count ? 1 : 0 };
1062
+ }
1063
+ /**
1064
+ * Deletes multiple documents matching the filter.
1065
+ * @param filter The criteria to select documents to delete.
1066
+ * @returns {Promise<DeleteResult>} An object describing the outcome.
1067
+ */
1068
+ async deleteMany(filter) {
1069
+ await this.ensureTable();
1070
+ const paramsForDelete = [];
1071
+ const whereClause = new findCursor_js_1.FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForDelete);
1072
+ // Get the number of documents that would be deleted
1073
+ const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
1074
+ const countResult = await this.db.get(countSql, paramsForDelete);
1075
+ const deleteSql = `DELETE FROM "${this.name}" WHERE ${whereClause}`;
1076
+ try {
1077
+ await this.db.run(deleteSql, paramsForDelete);
1078
+ }
1079
+ catch (error) {
1080
+ if (error.code === 'SQLITE_BUSY') {
1081
+ await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete many for filter ${JSON.stringify(filter)}`);
1082
+ }
1083
+ else {
1084
+ throw error;
1085
+ }
1086
+ }
1087
+ return { acknowledged: true, deletedCount: countResult ? countResult.count : 0 };
1088
+ }
1089
+ /**
1090
+ * Counts the number of documents matching the filter.
1091
+ * @param filter The criteria to select documents to count.
1092
+ * @returns {Promise<number>} The count of matching documents.
1093
+ */
1094
+ async countDocuments(filter = {}) {
1095
+ await this.ensureTable();
1096
+ const paramsForCount = [];
1097
+ const whereClause = new findCursor_js_1.FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForCount);
1098
+ const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
1099
+ const result = await this.db.get(countSql, paramsForCount);
1100
+ return result?.count || 0;
1101
+ }
1102
+ /**
1103
+ * Returns the number of documents in the collection.
1104
+ * This is an estimate and may not reflect the exact count in concurrent environments.
1105
+ * @returns {Promise<number>} The estimated count.
1106
+ */
1107
+ async estimatedDocumentCount() {
1108
+ await this.ensureTable();
1109
+ const result = await this.db.get(`SELECT COUNT(*) as count FROM "${this.name}"`);
1110
+ return result?.count || 0;
1111
+ }
1112
+ /**
1113
+ * Finds a single document matching the filter, applies the update, and returns the document.
1114
+ * @param filter The selection criteria.
1115
+ * @param update The modifications to apply.
1116
+ * @param options Options including returnDocument ('before'|'after') and upsert.
1117
+ * @returns {Promise<T | null>} The document before or after the update, or null.
1118
+ */
1119
+ async findOneAndUpdate(filter, update, options = {}) {
1120
+ await this.ensureTable();
1121
+ const { returnDocument = 'before', upsert = false, projection } = options;
1122
+ // Fetch the target document without projection so we always have the _id
1123
+ const idDocs = await this.find(filter).limit(1).toArray();
1124
+ const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
1125
+ const existingId = existingFullDoc?._id;
1126
+ if (!existingFullDoc && !upsert) {
1127
+ return null;
1128
+ }
1129
+ // Update by _id when available to ensure we modify the exact document we found
1130
+ const updateFilter = existingId !== undefined && existingId !== null
1131
+ ? { _id: existingId }
1132
+ : filter;
1133
+ const updateResult = await this.updateOne(updateFilter, update, { upsert });
1134
+ if (returnDocument === 'after') {
1135
+ const targetId = updateResult.upsertedId ?? existingId;
1136
+ if (!targetId)
1137
+ return null;
1138
+ const afterCursor = this.find({ _id: targetId }).limit(1);
1139
+ if (projection)
1140
+ afterCursor.project(projection);
1141
+ const afterDocs = await afterCursor.toArray();
1142
+ return afterDocs.length > 0 ? afterDocs[0] : null;
1143
+ }
1144
+ // returnDocument === 'before'
1145
+ if (!existingFullDoc) {
1146
+ // Upsert case where no original document existed
1147
+ return null;
1148
+ }
1149
+ if (!projection) {
1150
+ return existingFullDoc;
1151
+ }
1152
+ // Apply projection to the located document by _id
1153
+ if (existingId === undefined || existingId === null) {
1154
+ return null;
1155
+ }
1156
+ const beforeCursor = this.find({ _id: existingId }).limit(1);
1157
+ beforeCursor.project(projection);
1158
+ const beforeDocs = await beforeCursor.toArray();
1159
+ return beforeDocs.length > 0 ? beforeDocs[0] : null;
1160
+ }
1161
+ /**
1162
+ * Finds a single document matching the filter, deletes it, and returns it.
1163
+ * @param filter The selection criteria.
1164
+ * @param options Options including field projection.
1165
+ * @returns {Promise<T | null>} The deleted document or null.
1166
+ */
1167
+ async findOneAndDelete(filter, options = {}) {
1168
+ await this.ensureTable();
1169
+ const { projection } = options;
1170
+ // Fetch without projection to always have _id for the delete
1171
+ const idDocs = await this.find(filter).limit(1).toArray();
1172
+ const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
1173
+ if (!existingFullDoc) {
1174
+ return null;
1175
+ }
1176
+ await this.deleteOne({ _id: existingFullDoc._id });
1177
+ if (!projection) {
1178
+ return existingFullDoc;
1179
+ }
1180
+ // Apply projection to the in-memory document
1181
+ const projCursor = this.find({ _id: existingFullDoc._id }).limit(1);
1182
+ projCursor.project(projection);
1183
+ // Since we already deleted, apply projection manually from the full doc
1184
+ const fullDocRec = existingFullDoc;
1185
+ return this.applyAggregateProjection(fullDocRec, projection);
1186
+ }
1187
+ /**
1188
+ * Finds a single document matching the filter and replaces it entirely.
1189
+ * @param filter The selection criteria.
1190
+ * @param replacement The replacement document (replaces all fields except _id).
1191
+ * @param options Options including returnDocument, upsert, and projection.
1192
+ * @returns {Promise<T | null>} The document before or after the replacement, or null.
1193
+ */
1194
+ async findOneAndReplace(filter, replacement, options = {}) {
1195
+ await this.ensureTable();
1196
+ const { returnDocument = 'before', upsert = false, projection } = options;
1197
+ const existingDoc = await this.findOne(filter);
1198
+ if (!existingDoc && !upsert) {
1199
+ return null;
1200
+ }
1201
+ if (existingDoc) {
1202
+ const docId = existingDoc._id;
1203
+ const newData = safeJsonStringify(replacement, `findOneAndReplace for ${docId}`);
1204
+ await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
1205
+ if (returnDocument === 'after') {
1206
+ const afterCursor = this.find({ _id: docId }).limit(1);
1207
+ if (projection)
1208
+ afterCursor.project(projection);
1209
+ const afterDocs = await afterCursor.toArray();
1210
+ return afterDocs.length > 0 ? afterDocs[0] : null;
1211
+ }
1212
+ if (!projection) {
1213
+ return existingDoc;
1214
+ }
1215
+ // Apply projection to the pre-replacement document
1216
+ return this.applyAggregateProjection(existingDoc, projection);
1217
+ }
1218
+ else {
1219
+ // Upsert: insert the replacement. Carry _id from filter if it's a simple equality.
1220
+ const filterRec = filter;
1221
+ const filterId = filterRec['_id'] !== undefined &&
1222
+ filterRec['_id'] !== null &&
1223
+ typeof filterRec['_id'] !== 'object'
1224
+ ? filterRec['_id']
1225
+ : undefined;
1226
+ const docToInsert = filterId
1227
+ ? { ...replacement, _id: filterId }
1228
+ : replacement;
1229
+ const result = await this.insertOne(docToInsert);
1230
+ if (returnDocument === 'after') {
1231
+ const afterCursor = this.find({ _id: result.insertedId }).limit(1);
1232
+ if (projection)
1233
+ afterCursor.project(projection);
1234
+ const afterDocs = await afterCursor.toArray();
1235
+ return afterDocs.length > 0 ? afterDocs[0] : null;
1236
+ }
1237
+ return null;
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Replaces a single document matching the filter.
1242
+ * @param filter The selection criteria.
1243
+ * @param replacement The replacement document (replaces all fields except _id).
1244
+ * @param options Options including upsert.
1245
+ * @returns {Promise<UpdateResult>} An object describing the outcome.
1246
+ */
1247
+ async replaceOne(filter, replacement, options = {}) {
1248
+ await this.ensureTable();
1249
+ const { upsert = false } = options;
1250
+ const existingDoc = await this.findOne(filter);
1251
+ if (!existingDoc) {
1252
+ if (!upsert) {
1253
+ return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
1254
+ }
1255
+ const result = await this.insertOne(replacement);
1256
+ return {
1257
+ acknowledged: true,
1258
+ matchedCount: 0,
1259
+ modifiedCount: 0,
1260
+ upsertedId: result.insertedId,
1261
+ };
1262
+ }
1263
+ const docId = existingDoc._id;
1264
+ const newData = safeJsonStringify(replacement, `replaceOne for ${docId}`);
1265
+ await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
1266
+ return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
1267
+ }
1268
+ /**
1269
+ * Returns distinct values for a field across all documents matching the filter.
1270
+ * @param field The field to find distinct values for.
1271
+ * @param filter Optional filter to narrow the documents.
1272
+ * @returns {Promise<unknown[]>} An array of distinct values.
1273
+ */
1274
+ async distinct(field, filter = {}) {
1275
+ await this.ensureTable();
1276
+ const docs = await this.find(filter).toArray();
1277
+ const seen = new Set();
1278
+ const results = [];
1279
+ for (const doc of docs) {
1280
+ const value = this.getNestedValue(doc, field);
1281
+ if (Array.isArray(value)) {
1282
+ for (const item of value) {
1283
+ const key = JSON.stringify(item);
1284
+ if (!seen.has(key)) {
1285
+ seen.add(key);
1286
+ results.push(item);
1287
+ }
1288
+ }
1289
+ }
1290
+ else if (value !== undefined) {
1291
+ const key = JSON.stringify(value);
1292
+ if (!seen.has(key)) {
1293
+ seen.add(key);
1294
+ results.push(value);
1295
+ }
1296
+ }
1297
+ }
1298
+ return results;
1299
+ }
1300
+ /**
1301
+ * Drops the entire collection (table) from the database.
1302
+ * @returns {Promise<void>}
1303
+ */
1304
+ async drop() {
1305
+ await this.db.exec(`DROP TABLE IF EXISTS "${this.name}"`);
1306
+ }
1307
+ /**
1308
+ * Executes an aggregation pipeline on the collection.
1309
+ * Supports: $match, $project, $sort, $limit, $skip, $count, $group, $unwind, $addFields.
1310
+ * @param pipeline An array of pipeline stage documents.
1311
+ * @returns An object with a toArray() method that returns the aggregation result.
1312
+ */
1313
+ aggregate(pipeline) {
1314
+ return {
1315
+ toArray: async () => {
1316
+ await this.ensureTable();
1317
+ return this.runAggregationPipeline(pipeline);
1318
+ },
1319
+ };
1320
+ }
1321
+ /**
1322
+ * Runs an aggregation pipeline and returns results.
1323
+ * @private
1324
+ */
1325
+ async runAggregationPipeline(pipeline) {
1326
+ let results = [];
1327
+ // Only push the initial $match down to SQL when it is the very first stage.
1328
+ // Applying a $match that appears after other stages (e.g. after $unwind/$group) too
1329
+ // early would change pipeline semantics.
1330
+ const firstStage = pipeline[0];
1331
+ const firstIsMatch = firstStage !== undefined && '$match' in firstStage;
1332
+ if (firstIsMatch) {
1333
+ const matchFilter = firstStage.$match;
1334
+ const docs = await this.find(matchFilter).toArray();
1335
+ results = docs;
1336
+ }
1337
+ else {
1338
+ const docs = await this.find({}).toArray();
1339
+ results = docs;
1340
+ }
1341
+ for (let i = 0; i < pipeline.length; i++) {
1342
+ const stage = pipeline[i];
1343
+ const stageKey = Object.keys(stage)[0];
1344
+ switch (stageKey) {
1345
+ case '$match': {
1346
+ // Skip the first stage if it was already used for the initial SQL fetch
1347
+ if (i === 0 && firstIsMatch) {
1348
+ break;
1349
+ }
1350
+ const matchFilter = stage['$match'];
1351
+ results = results.filter((doc) => this.matchesFilter(doc, matchFilter));
1352
+ break;
1353
+ }
1354
+ case '$project': {
1355
+ const projection = stage['$project'];
1356
+ results = results.map((doc) => this.applyAggregateProjection(doc, projection));
1357
+ break;
1358
+ }
1359
+ case '$addFields': {
1360
+ const fields = stage['$addFields'];
1361
+ results = results.map((doc) => ({ ...doc, ...fields }));
1362
+ break;
1363
+ }
1364
+ case '$sort': {
1365
+ const sortSpec = stage['$sort'];
1366
+ results = results.sort((a, b) => {
1367
+ for (const [field, order] of Object.entries(sortSpec)) {
1368
+ const aVal = this.getNestedValue(a, field);
1369
+ const bVal = this.getNestedValue(b, field);
1370
+ if (aVal === bVal)
1371
+ continue;
1372
+ if (aVal === null || aVal === undefined)
1373
+ return order;
1374
+ if (bVal === null || bVal === undefined)
1375
+ return -order;
1376
+ const cmp = aVal < bVal ? -1 : 1;
1377
+ return cmp * order;
1378
+ }
1379
+ return 0;
1380
+ });
1381
+ break;
1382
+ }
1383
+ case '$limit': {
1384
+ const limitCount = stage['$limit'];
1385
+ results = results.slice(0, limitCount);
1386
+ break;
1387
+ }
1388
+ case '$skip': {
1389
+ const skipCount = stage['$skip'];
1390
+ results = results.slice(skipCount);
1391
+ break;
1392
+ }
1393
+ case '$count': {
1394
+ const countField = stage['$count'];
1395
+ results = [{ [countField]: results.length }];
1396
+ break;
1397
+ }
1398
+ case '$group': {
1399
+ results = this.applyGroupStage(results, stage['$group']);
1400
+ break;
1401
+ }
1402
+ case '$unwind': {
1403
+ const path = typeof stage['$unwind'] === 'string'
1404
+ ? stage['$unwind']
1405
+ : stage['$unwind'].path;
1406
+ const fieldName = path.startsWith('$') ? path.slice(1) : path;
1407
+ const unwound = [];
1408
+ for (const doc of results) {
1409
+ const arrayVal = this.getNestedValue(doc, fieldName);
1410
+ if (Array.isArray(arrayVal)) {
1411
+ for (const item of arrayVal) {
1412
+ const newDoc = { ...doc };
1413
+ this.setNestedValue(newDoc, fieldName, item);
1414
+ unwound.push(newDoc);
1415
+ }
1416
+ }
1417
+ else if (arrayVal !== undefined && arrayVal !== null) {
1418
+ unwound.push(doc);
1419
+ }
1420
+ }
1421
+ results = unwound;
1422
+ break;
1423
+ }
1424
+ }
1425
+ }
1426
+ return results;
1427
+ }
1428
+ /**
1429
+ * Applies a $group pipeline stage.
1430
+ * @private
1431
+ */
1432
+ applyGroupStage(docs, groupSpec) {
1433
+ const groups = new Map();
1434
+ const idSpec = groupSpec['_id'];
1435
+ for (const doc of docs) {
1436
+ let groupKey;
1437
+ if (idSpec === null) {
1438
+ groupKey = null;
1439
+ }
1440
+ else if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
1441
+ groupKey = this.getNestedValue(doc, idSpec.slice(1));
1442
+ }
1443
+ else if (typeof idSpec === 'object' && idSpec !== null) {
1444
+ const keyObj = {};
1445
+ for (const [k, expr] of Object.entries(idSpec)) {
1446
+ if (typeof expr === 'string' && expr.startsWith('$')) {
1447
+ keyObj[k] = this.getNestedValue(doc, expr.slice(1));
1448
+ }
1449
+ else {
1450
+ keyObj[k] = expr;
1451
+ }
1452
+ }
1453
+ groupKey = keyObj;
1454
+ }
1455
+ else {
1456
+ groupKey = idSpec;
1457
+ }
1458
+ const keyStr = JSON.stringify(groupKey);
1459
+ if (!groups.has(keyStr)) {
1460
+ groups.set(keyStr, []);
1461
+ }
1462
+ groups.get(keyStr).push(doc);
1463
+ }
1464
+ const results = [];
1465
+ for (const [keyStr, groupDocs] of groups) {
1466
+ const groupResult = { _id: JSON.parse(keyStr) };
1467
+ for (const [field, expr] of Object.entries(groupSpec)) {
1468
+ if (field === '_id')
1469
+ continue;
1470
+ if (typeof expr === 'object' && expr !== null) {
1471
+ const aggOp = Object.keys(expr)[0];
1472
+ const aggArg = expr[aggOp];
1473
+ switch (aggOp) {
1474
+ case '$sum': {
1475
+ if (typeof aggArg === 'number') {
1476
+ groupResult[field] = groupDocs.length * aggArg;
1477
+ }
1478
+ else if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1479
+ const fieldName = aggArg.slice(1);
1480
+ groupResult[field] = groupDocs.reduce((sum, doc) => {
1481
+ const val = this.getNestedValue(doc, fieldName);
1482
+ return sum + (typeof val === 'number' ? val : 0);
1483
+ }, 0);
1484
+ }
1485
+ break;
1486
+ }
1487
+ case '$avg': {
1488
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1489
+ const fieldName = aggArg.slice(1);
1490
+ const nums = groupDocs
1491
+ .map((doc) => this.getNestedValue(doc, fieldName))
1492
+ .filter((v) => typeof v === 'number');
1493
+ groupResult[field] = nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
1494
+ }
1495
+ break;
1496
+ }
1497
+ case '$min': {
1498
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1499
+ const fieldName = aggArg.slice(1);
1500
+ const vals = groupDocs
1501
+ .map((doc) => this.getNestedValue(doc, fieldName))
1502
+ .filter((v) => v !== undefined && v !== null);
1503
+ groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
1504
+ }
1505
+ break;
1506
+ }
1507
+ case '$max': {
1508
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1509
+ const fieldName = aggArg.slice(1);
1510
+ const vals = groupDocs
1511
+ .map((doc) => this.getNestedValue(doc, fieldName))
1512
+ .filter((v) => v !== undefined && v !== null);
1513
+ groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
1514
+ }
1515
+ break;
1516
+ }
1517
+ case '$push': {
1518
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1519
+ const fieldName = aggArg.slice(1);
1520
+ groupResult[field] = groupDocs.map((doc) => this.getNestedValue(doc, fieldName));
1521
+ }
1522
+ else {
1523
+ groupResult[field] = groupDocs.map(() => aggArg);
1524
+ }
1525
+ break;
1526
+ }
1527
+ case '$first': {
1528
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1529
+ const fieldName = aggArg.slice(1);
1530
+ groupResult[field] =
1531
+ groupDocs.length > 0 ? this.getNestedValue(groupDocs[0], fieldName) : null;
1532
+ }
1533
+ break;
1534
+ }
1535
+ case '$last': {
1536
+ if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
1537
+ const fieldName = aggArg.slice(1);
1538
+ groupResult[field] =
1539
+ groupDocs.length > 0
1540
+ ? this.getNestedValue(groupDocs[groupDocs.length - 1], fieldName)
1541
+ : null;
1542
+ }
1543
+ break;
1544
+ }
1545
+ case '$count': {
1546
+ groupResult[field] = groupDocs.length;
1547
+ break;
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ results.push(groupResult);
1553
+ }
1554
+ return results;
1555
+ }
1556
+ /**
1557
+ * Applies a $project stage to a document.
1558
+ * @private
1559
+ */
1560
+ applyAggregateProjection(doc, projection) {
1561
+ if (Object.keys(projection).length === 0)
1562
+ return doc;
1563
+ // Computed/renamed fields (string values starting with '$') are aggregation-only semantics
1564
+ // that plain find() projections don't have, so they're layered on top of the shared
1565
+ // inclusion/exclusion mechanics rather than folded into it. They also count as an inclusion
1566
+ // for mode-determination purposes, matching real $project behavior.
1567
+ const modeInput = {};
1568
+ for (const [key, value] of Object.entries(projection)) {
1569
+ modeInput[key] = typeof value === 'string' && value.startsWith('$') ? 1 : value;
1570
+ }
1571
+ const mode = (0, projection_js_1.resolveProjectionMode)(modeInput);
1572
+ const base = (0, projection_js_1.applyProjectionToDocument)(doc, projection);
1573
+ if (mode !== 'include')
1574
+ return base;
1575
+ const result = { ...base };
1576
+ for (const [field, val] of Object.entries(projection)) {
1577
+ if (typeof val === 'string' && val.startsWith('$')) {
1578
+ result[field] = this.getNestedValue(doc, val.slice(1));
1579
+ }
1580
+ }
1581
+ return result;
1582
+ }
1583
+ /**
1584
+ * Checks if a document matches a filter (for in-memory aggregation stages).
1585
+ * Uses the same logic as FindCursor but applied in JavaScript.
1586
+ * @private
1587
+ */
1588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1589
+ matchesFilter(doc, filter) {
1590
+ for (const [key, value] of Object.entries(filter)) {
1591
+ if (key === '$and' && Array.isArray(value)) {
1592
+ if (!value.every((f) => this.matchesFilter(doc, f)))
1593
+ return false;
1594
+ continue;
1595
+ }
1596
+ if (key === '$or' && Array.isArray(value)) {
1597
+ if (!value.some((f) => this.matchesFilter(doc, f)))
1598
+ return false;
1599
+ continue;
1600
+ }
1601
+ if (key === '$nor' && Array.isArray(value)) {
1602
+ if (value.some((f) => this.matchesFilter(doc, f)))
1603
+ return false;
1604
+ continue;
1605
+ }
1606
+ if (key.startsWith('$'))
1607
+ continue;
1608
+ const docValue = this.getNestedValue(doc, key);
1609
+ if (typeof value === 'object' && value !== null && !(value instanceof RegExp) && !(value instanceof Date)) {
1610
+ const ops = value;
1611
+ const hasOps = Object.keys(ops).some((k) => k.startsWith('$'));
1612
+ if (hasOps) {
1613
+ if (!this.matchesOperators(docValue, ops))
1614
+ return false;
1615
+ continue;
1616
+ }
1617
+ }
1618
+ // Simple equality (including Date comparison)
1619
+ if (value instanceof Date) {
1620
+ if (!(docValue instanceof Date) || docValue.getTime() !== value.getTime())
1621
+ return false;
1622
+ }
1623
+ else if (Array.isArray(docValue)) {
1624
+ if (!docValue.some((el) => JSON.stringify(el) === JSON.stringify(value)))
1625
+ return false;
1626
+ }
1627
+ else if (JSON.stringify(docValue) !== JSON.stringify(value)) {
1628
+ return false;
1629
+ }
1630
+ }
1631
+ return true;
1632
+ }
1633
+ /**
1634
+ * Checks if a value matches a set of query operators (for in-memory filtering).
1635
+ * @private
1636
+ */
1637
+ matchesOperators(docValue, ops) {
1638
+ return (0, queryOperators_js_1.evaluateOperators)(docValue, ops);
1639
+ }
1640
+ /**
1641
+ * Opens a change stream to watch for changes on this collection.
1642
+ * Returns a ChangeStream that emits events when documents are inserted, updated, or deleted.
1643
+ *
1644
+ * @param options Options for the change stream
1645
+ * @returns A ChangeStream instance
1646
+ *
1647
+ * @example
1648
+ * ```typescript
1649
+ * const changeStream = collection.watch();
1650
+ *
1651
+ * changeStream.on('change', (change) => {
1652
+ * console.log('Change detected:', change);
1653
+ * });
1654
+ *
1655
+ * // Or use async iteration
1656
+ * for await (const change of changeStream) {
1657
+ * console.log('Change detected:', change);
1658
+ * }
1659
+ *
1660
+ * // Close the change stream when done
1661
+ * changeStream.close();
1662
+ * ```
1663
+ */
1664
+ watch(options = {}) {
1665
+ return new changeStream_js_1.ChangeStream(this.db, this.name, options);
1666
+ }
1667
+ /**
1668
+ * Retries an operation with exponential backoff when SQLITE_BUSY errors occur.
1669
+ * @param operation The operation function to retry.
1670
+ * @param operationDescription A description of the operation for logging purposes.
1671
+ * @param maxRetries The maximum number of retry attempts (default: 5).
1672
+ * @param initialDelayMs The initial delay before the first retry in milliseconds (default: 100).
1673
+ * @param maxDelayMs The maximum delay between retries in milliseconds (default: 10000).
1674
+ * @returns The result of the operation once successful.
1675
+ * @private
1676
+ */
1677
+ async retryWithBackoff(operation, operationDescription, maxRetries = 5, initialDelayMs = 100, maxDelayMs = 10000) {
1678
+ let retryCount = 0;
1679
+ let delayMs = initialDelayMs;
1680
+ while (true) {
1681
+ try {
1682
+ if (retryCount > 0) {
1683
+ console.warn(`Retry attempt ${retryCount}/${maxRetries} for ${operationDescription} after ${delayMs}ms delay...`);
1684
+ }
1685
+ return await operation();
1686
+ }
1687
+ catch (error) {
1688
+ if (error.code === 'SQLITE_BUSY' && retryCount < maxRetries) {
1689
+ retryCount++;
1690
+ // Wait using exponential backoff with jitter
1691
+ await new Promise((resolve) => {
1692
+ // Add some randomness (jitter) to prevent multiple clients from retrying simultaneously
1693
+ const jitter = Math.random() * 0.3 + 0.85; // Random factor between 0.85 and 1.15
1694
+ delayMs = Math.min(delayMs * 2 * jitter, maxDelayMs);
1695
+ setTimeout(resolve, delayMs);
1696
+ });
1697
+ // Continue to next iteration to retry
1698
+ continue;
1699
+ }
1700
+ // If we've exhausted retries or it's a different error, rethrow
1701
+ if (retryCount >= maxRetries && error.code === 'SQLITE_BUSY') {
1702
+ console.error(`Maximum retries (${maxRetries}) reached for ${operationDescription}. Operation failed.`);
1703
+ }
1704
+ throw error;
1705
+ }
1706
+ }
1707
+ }
1708
+ }
1709
+ exports.MongoLiteCollection = MongoLiteCollection;