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